diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5fbd2ed..18bfb6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,18 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e - why - testing +## System Workflows + +System workflows live in `mike-workflows/system/`. Put structured metadata in +the YAML frontmatter at the top of `SKILL.md`, put workflow instructions in the +body of `SKILL.md`, and use `table-config.yaml` 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. diff --git a/backend/bun.lock b/backend/bun.lock index 90061e1..745ff4a 100644 --- a/backend/bun.lock +++ b/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,8 @@ "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": { "@types/cors": "^2.8.17", @@ -179,6 +182,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 +386,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 +434,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 +484,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 +542,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 +554,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 +564,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 +608,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 +636,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 +652,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 +674,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 +684,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 +712,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,8 +764,14 @@ "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=="], + "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }], + "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], @@ -729,6 +780,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 +792,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 +832,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=="], } } diff --git a/backend/migrations/20260613_05_workflows_overview_rpc.sql b/backend/migrations/20260613_05_workflows_overview_rpc.sql index d20a5bf..f7b7c77 100644 --- a/backend/migrations/20260613_05_workflows_overview_rpc.sql +++ b/backend/migrations/20260613_05_workflows_overview_rpc.sql @@ -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) ), diff --git a/backend/migrations/20260625_01_workflow_metadata.sql b/backend/migrations/20260625_01_workflow_metadata.sql new file mode 100644 index 0000000..3391c6b --- /dev/null +++ b/backend/migrations/20260625_01_workflow_metadata.sql @@ -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; +$$; diff --git a/backend/migrations/20260629_01_workflow_open_source_submissions.sql b/backend/migrations/20260629_01_workflow_open_source_submissions.sql new file mode 100644 index 0000000..f2a11ee --- /dev/null +++ b/backend/migrations/20260629_01_workflow_open_source_submissions.sql @@ -0,0 +1,38 @@ +-- 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, + 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; diff --git a/backend/migrations/20260703_01_user_profile_email.sql b/backend/migrations/20260703_01_user_profile_email.sql new file mode 100644 index 0000000..27e5a53 --- /dev/null +++ b/backend/migrations/20260703_01_user_profile_email.sql @@ -0,0 +1,41 @@ +-- Mirror auth.users.email into user_profiles so backend sharing checks can +-- resolve one email without scanning Supabase Auth users. + +alter table public.user_profiles + add column if not exists email text; + +update public.user_profiles up +set email = lower(au.email) +from auth.users au +where up.user_id = au.id + and au.email is not null + and ( + up.email is null + or up.email <> lower(au.email) + ); + +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 +security definer +set search_path = public +as $$ +begin + 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. + return new; +end; +$$; diff --git a/backend/migrations/20260703_02_project_practice.sql b/backend/migrations/20260703_02_project_practice.sql new file mode 100644 index 0000000..22c811e --- /dev/null +++ b/backend/migrations/20260703_02_project_practice.sql @@ -0,0 +1,84 @@ +-- Add optional practice metadata to projects and expose it in the overview RPC. + +alter table public.projects + add column if not exists practice text; + +drop function if exists public.get_projects_overview(text, text); + +create or replace function public.get_projects_overview( + p_user_id text, + p_user_email text default null +) +returns table ( + id uuid, + user_id text, + name text, + cm_number text, + practice text, + shared_with jsonb, + created_at timestamptz, + updated_at timestamptz, + is_owner boolean, + owner_display_name text, + owner_email text, + document_count integer, + chat_count integer, + review_count integer +) +language sql +stable +as $$ + with visible_projects as ( + select p.* + from public.projects p + where p.user_id = p_user_id + or ( + coalesce(p_user_email, '') <> '' + and p.user_id <> p_user_id + and p.shared_with @> jsonb_build_array(p_user_email) + ) + ), + document_counts as ( + select d.project_id, count(*)::integer as document_count + from public.documents d + where d.project_id in (select vp.id from visible_projects vp) + group by d.project_id + ), + chat_counts as ( + select c.project_id, count(*)::integer as chat_count + from public.chats c + where c.project_id in (select vp.id from visible_projects vp) + group by c.project_id + ), + review_counts as ( + select tr.project_id, count(*)::integer as review_count + from public.tabular_reviews tr + where tr.project_id in (select vp.id from visible_projects vp) + group by tr.project_id + ) + select + vp.id, + vp.user_id, + vp.name, + vp.cm_number, + vp.practice, + vp.shared_with, + vp.created_at, + vp.updated_at, + vp.user_id = p_user_id as is_owner, + nullif(trim(up.display_name), '') as owner_display_name, + null::text as owner_email, + coalesce(dc.document_count, 0) as document_count, + coalesce(cc.chat_count, 0) as chat_count, + coalesce(rc.review_count, 0) as review_count + from visible_projects vp + left join public.user_profiles up + on up.user_id::text = vp.user_id + left join document_counts dc + on dc.project_id = vp.id + left join chat_counts cc + on cc.project_id = vp.id + left join review_counts rc + on rc.project_id = vp.id + order by vp.created_at desc; +$$; diff --git a/backend/migrations/20260704_01_chat_message_citations.sql b/backend/migrations/20260704_01_chat_message_citations.sql new file mode 100644 index 0000000..adf6108 --- /dev/null +++ b/backend/migrations/20260704_01_chat_message_citations.sql @@ -0,0 +1,19 @@ +do $$ +begin + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'chat_messages' + and column_name = 'annotations' + ) and not exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'chat_messages' + and column_name = 'citations' + ) then + alter table public.chat_messages + rename column annotations to citations; + end if; +end $$; diff --git a/backend/package-lock.json b/backend/package-lock.json index ead4e3b..c7597a9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index fb7a18b..195a6ac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,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": { diff --git a/backend/schema.sql b/backend/schema.sql index 9dec314..5e48fa5 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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, diff --git a/backend/src/lib/builtinWorkflows.ts b/backend/src/lib/builtinWorkflows.ts deleted file mode 100644 index 7154d77..0000000 --- a/backend/src/lib/builtinWorkflows.ts +++ /dev/null @@ -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.", - }, -]; diff --git a/backend/src/lib/chat/citations.ts b/backend/src/lib/chat/citations.ts new file mode 100644 index 0000000..cd7fb0a --- /dev/null +++ b/backend/src/lib/chat/citations.ts @@ -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; + 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, +): { 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, +): 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; + 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) { + 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; + 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 = /\s*([\s\S]*?)\s*<\/CITATIONS>/; +export const CITATIONS_OPEN_TAG = ""; +export const CITATIONS_CLOSE_TAG = ""; + +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; + +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, + }; +} diff --git a/backend/src/lib/chat/contextBuilders.ts b/backend/src/lib/chat/contextBuilders.ts new file mode 100644 index 0000000..e580113 --- /dev/null +++ b/backend/src/lib/chat/contextBuilders.ts @@ -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, + docIndex: DocIndex, +): Promise { + 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(); + 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[]) { + 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; + 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(); + 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[]) | 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; + 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; + 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, + chatId: string, + response: AskInputsResponseRequest, +) { + await appendAssistantEventsToLastAssistantMessage(db, chatId, [ + { + type: "ask_inputs_response" as const, + responses: response.responses, + }, + ]); +} + +export async function appendAssistantEventsToLastAssistantMessage( + db: ReturnType, + 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, + chatId?: string | null, +): Promise<{ docIndex: DocIndex; docStore: DocStore }> { + const docIndex: DocIndex = {}; + const docStore: DocStore = new Map(); + + const documentIds = new Set(); + 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[]) { + 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, +): Promise<{ + docIndex: DocIndex; + docStore: DocStore; + folderPaths: Map; +}> { + 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(); // 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, +): Promise { + 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, skill_md: wf.skill_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, skill_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, + skill_md: wf.prompt_md, + }); + } + } + } + } + return store; +} diff --git a/backend/src/lib/chat/index.ts b/backend/src/lib/chat/index.ts new file mode 100644 index 0000000..081c627 --- /dev/null +++ b/backend/src/lib/chat/index.ts @@ -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"; diff --git a/backend/src/lib/chat/prompts.ts b/backend/src/lib/chat/prompts.ts new file mode 100644 index 0000000..d7f7b88 --- /dev/null +++ b/backend/src/lib/chat/prompts.ts @@ -0,0 +1,83 @@ +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: (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. +- A cell tagged "⟨merged A1:C1⟩" spans that whole range: its value belongs to the anchor cell and the other covered cells are shown blank. When citing anything in a merged range, set "cell" to the full range from the tag (e.g. "A1:C1"), not a covered cell like "B1". Do not include the "⟨merged ...⟩" tag text in "quote". +- 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); diff --git a/backend/src/lib/chat/streaming.ts b/backend/src/lib/chat/streaming.ts new file mode 100644 index 0000000..f6ddacb --- /dev/null +++ b/backend/src/lib/chat/streaming.ts @@ -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 }; +} diff --git a/backend/src/lib/legalSourcesTools/courtlistenerTools.ts b/backend/src/lib/chat/tools/courtlistenerTools.ts similarity index 100% rename from backend/src/lib/legalSourcesTools/courtlistenerTools.ts rename to backend/src/lib/chat/tools/courtlistenerTools.ts diff --git a/backend/src/lib/chat/tools/documentOps.ts b/backend/src/lib/chat/tools/documentOps.ts new file mode 100644 index 0000000..1ca2a55 --- /dev/null +++ b/backend/src/lib/chat/tools/documentOps.ts @@ -0,0 +1,1813 @@ +import { + downloadFile, + generatedDocKey, + uploadFile, +} from "../../storage"; +import { convertedPdfKey, docxToPdf } from "../../convert"; +import { createServerSupabase } from "../../supabase"; +import { + applyTrackedEdits, + extractDocxBodyText, + type EditInput, +} from "../../docxTrackedChanges"; +import { buildDownloadUrl } from "../../downloadTokens"; +import { loadActiveVersion } from "../../documentVersions"; +import { + type DocStore, + type DocIndex, + type EditAnnotation, + STANDARD_FONT_DATA_URL, + devLog, +} from "../types"; +import { + contentTypeForDocumentType, + isPresentationDocumentType, + isSpreadsheetDocumentType, + isWordDocumentType, + shouldConvertToPdf, +} from "../../documentTypes"; +import { extractPresentationText } from "../../officeText"; +import { spreadsheetToLLMText } from "../../spreadsheet"; + + +export function citationReminder(docLabel: string, filename: string): string { + const isSpreadsheet = isSpreadsheetDocumentType( + filename.split(".").pop() ?? "", + ); + const shapeLine = isSpreadsheet + ? `Use this citation object shape for this spreadsheet: {"ref": 1, "doc_id": "${docLabel}", "quotes": [{"sheet": "Sheet name", "cell": "B7", "quote": "plain cell value"}]}. Cite by "sheet" + "cell" (A1 address or range), not by page.` + : `Use this citation object shape: {"ref": 1, "doc_id": "${docLabel}", "quotes": [{"page": 1, "quote": "exact verbatim text from the document"}]}. Include top-level "page" and "quote" too only if they match the first quote.`; + return [ + `[Citation requirement for ${docLabel} ("${filename}")]:`, + `If your final answer makes any factual claim from this document, include inline [N] markers and append a final <CITATIONS> JSON block.`, + `Every citation entry for this document MUST use "doc_id": "${docLabel}".`, + shapeLine, + `Do not use "marker" or "text" keys in the citation block; use "ref" and "quotes".`, + ].join("\n"); +} + +export async function extractPdfText(buf: ArrayBuffer): Promise<string> { + try { + const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs" as string); + const pdf = await ( + pdfjsLib as unknown as { + getDocument: (opts: unknown) => { + promise: Promise<{ + numPages: number; + getPage: (n: number) => Promise<{ + getTextContent: () => Promise<{ + items: { str?: string }[]; + }>; + }>; + }>; + }; + } + ).getDocument({ + data: new Uint8Array(buf), + standardFontDataUrl: STANDARD_FONT_DATA_URL, + }).promise; + const parts: string[] = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + parts.push( + `[Page ${i}]\n${textContent.items.map((it) => it.str ?? "").join(" ")}`, + ); + } + return parts.join("\n\n"); + } catch { + return ""; + } +} + +export async function generateDocx( + title: string, + sections: unknown[], + userId: string, + db: ReturnType<typeof createServerSupabase>, + options?: { landscape?: boolean; projectId?: string | null }, +) { + try { + const { + Document, + Paragraph, + HeadingLevel, + Packer, + Table, + TableRow, + TableCell, + WidthType, + BorderStyle, + TextRun, + AlignmentType, + LevelFormat, + LevelSuffix, + PageOrientation, + PageBreak, + } = await import("docx"); + + const FONT = "Times New Roman"; + const SIZE = 22; // 11pt in half-points + + type DocChild = InstanceType<typeof Paragraph> | InstanceType<typeof Table>; + const children: DocChild[] = []; + children.push( + new Paragraph({ + heading: HeadingLevel.TITLE, + spacing: { after: 200 }, + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: title.toUpperCase(), + color: "000000", + font: FONT, + size: SIZE, + bold: true, + }), + ], + }), + ); + + const cellBorder = { + top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + }; + + const headingLevels = [ + HeadingLevel.HEADING_1, + HeadingLevel.HEADING_2, + HeadingLevel.HEADING_3, + HeadingLevel.HEADING_4, + ]; + const LEGAL_NUMBERING_REF = "legal-clause-numbering"; + const legalNumbering = (level: number) => ({ + reference: LEGAL_NUMBERING_REF, + level: Math.max(0, Math.min(level, 4)), + }); + const legalNumberingLevels = [ + { + level: 0, + format: LevelFormat.DECIMAL, + text: "%1.", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + isLegalNumberingStyle: true, + style: { + paragraph: { indent: { left: 720, hanging: 720 } }, + run: { + bold: true, + color: "000000", + font: FONT, + size: SIZE, + }, + }, + }, + { + level: 1, + format: LevelFormat.DECIMAL, + text: "%1.%2", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + isLegalNumberingStyle: true, + style: { + paragraph: { indent: { left: 720, hanging: 720 } }, + run: { color: "000000", font: FONT, size: SIZE }, + }, + }, + { + level: 2, + format: LevelFormat.LOWER_LETTER, + text: "(%3)", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + style: { + paragraph: { indent: { left: 1440, hanging: 720 } }, + run: { color: "000000", font: FONT, size: SIZE }, + }, + }, + { + level: 3, + format: LevelFormat.LOWER_ROMAN, + text: "(%4)", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + style: { + paragraph: { indent: { left: 1440, hanging: 720 } }, + run: { color: "000000", font: FONT, size: SIZE }, + }, + }, + { + level: 4, + format: LevelFormat.UPPER_LETTER, + text: "(%5)", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + style: { + paragraph: { indent: { left: 2520, hanging: 720 } }, + run: { color: "000000", font: FONT, size: SIZE }, + }, + }, + ]; + const normalizeTable = ( + table: unknown, + ): { headers: string[]; rows: string[][] } | null => { + if (!table || typeof table !== "object") return null; + const raw = table as { headers?: unknown; rows?: unknown }; + const headers = Array.isArray(raw.headers) + ? raw.headers + .map((header) => (typeof header === "string" ? header.trim() : "")) + .filter(Boolean) + : []; + if (headers.length === 0) return null; + + const rawRows = Array.isArray(raw.rows) ? raw.rows : []; + const rows = rawRows + .filter((row): row is unknown[] => Array.isArray(row)) + .map((row) => + headers.map((_, i) => (typeof row[i] === "string" ? row[i] : "")), + ); + + return { headers, rows }; + }; + const stripManualNumbering = ( + value: string, + ): { text: string; levelFromPrefix: number | null } => { + const match = value.trim().match(/^(\d+(?:\.\d+)*)(?:[.)])?\s+(.+)$/); + if (!match) return { text: value.trim(), levelFromPrefix: null }; + return { + text: match[2].trim(), + levelFromPrefix: match[1].split(".").length - 1, + }; + }; + const parseManualListMarker = ( + value: string, + ): { text: string; levelOffset: number | null } => { + const trimmed = value.trim(); + const match = trimmed.match(/^(\(([a-z]+)\)|([a-z]+)[.)])\s+(.+)$/i); + if (!match) return { text: trimmed, levelOffset: null }; + const marker = (match[2] ?? match[3] ?? "").toLowerCase(); + const isRoman = + marker === "i" || + (marker.length > 1 && + /^(?:m{0,4}(?:cm|cd|d?c{0,3})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$/i.test( + marker, + )); + return { text: match[4].trim(), levelOffset: isRoman ? 3 : 2 }; + }; + const normalizeHeadingText = (value: string) => + value + .trim() + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() + .toLowerCase(); + + const isTitleLikeFirstHeading = (heading: string, sectionIndex: number) => { + if (sectionIndex !== 0) return false; + const normalized = normalizeHeadingText(heading); + const titleNormalized = normalizeHeadingText(title); + if (!normalized || !titleNormalized) return false; + if (normalized === titleNormalized) return true; + return ( + titleNormalized.includes(normalized) && + /\b(agreement|contract|deed|terms|policy|notice|nda|disclosure)\b/.test( + normalized, + ) + ); + }; + + const isUnnumberedHeading = (heading: string, sectionIndex: number) => { + const normalized = normalizeHeadingText(heading); + if (!normalized) return true; + if (normalized === "signatures" || normalized === "signature") { + return true; + } + if (isTitleLikeFirstHeading(heading, sectionIndex)) { + return true; + } + if ( + sectionIndex === 0 && + /^(agreement|contract|mutual non disclosure agreement|non disclosure agreement|employment agreement|service level agreement)$/.test( + normalized, + ) + ) { + return true; + } + return false; + }; + const isSignatureLine = (value: string) => + /^(?:by|name|title|date):\s*/i.test(value.trim()); + const looksLikeSignatureBlock = (value: string) => { + const lines = value + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) return false; + const signatureLineCount = lines.filter(isSignatureLine).length; + return signatureLineCount >= 2; + }; + let currentClauseLevel: number | null = null; + + for (const [sectionIndex, section] of ( + sections as { + heading?: string; + content?: string; + level?: number; + pageBreak?: boolean; + table?: { headers: string[]; rows: string[][] }; + }[] + ).entries()) { + if (section.pageBreak) { + children.push(new Paragraph({ children: [new PageBreak()] })); + } + if (section.heading) { + const stripped = stripManualNumbering(section.heading); + const isUnnumbered = isUnnumberedHeading(stripped.text, sectionIndex); + const skipHeading = isTitleLikeFirstHeading( + stripped.text, + sectionIndex, + ); + const idx = Math.min( + stripped.levelFromPrefix ?? (section.level ?? 1) - 1, + 3, + ); + currentClauseLevel = isUnnumbered || skipHeading ? null : idx; + const headingText = + idx === 0 && !isUnnumbered + ? stripped.text.toUpperCase() + : stripped.text; + if (!skipHeading) { + children.push( + new Paragraph({ + heading: headingLevels[idx], + numbering: isUnnumbered ? undefined : legalNumbering(idx), + spacing: { after: 160 }, + children: [ + new TextRun({ + text: headingText, + color: "000000", + font: FONT, + size: SIZE, + bold: true, + }), + ], + }), + ); + } + } + const normalizedTable = normalizeTable(section.table); + if (normalizedTable) { + const { headers, rows } = normalizedTable; + const tableRows: InstanceType<typeof TableRow>[] = []; + // Header row + tableRows.push( + new TableRow({ + tableHeader: true, + children: headers.map( + (h) => + new TableCell({ + borders: cellBorder, + shading: { fill: "F2F2F2" }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: h, + bold: true, + font: FONT, + size: SIZE, + }), + ], + alignment: AlignmentType.LEFT, + }), + ], + }), + ), + }), + ); + // Data rows — normalize each row to exactly colCount cells. + // LLMs occasionally emit malformed rows (extra fragments from + // stray delimiters, or short rows); padding/truncating here + // keeps the rendered table aligned to the headers. + for (const normalized of rows) { + tableRows.push( + new TableRow({ + children: normalized.map( + (cell) => + new TableCell({ + borders: cellBorder, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: cell, + font: FONT, + size: SIZE, + }), + ], + }), + ], + }), + ), + }), + ); + } + children.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: tableRows, + }), + ); + children.push(new Paragraph({ text: "" })); + } + if (section.content) { + let numberedBodyParagraphs = 0; + const contentIsSignatureBlock = + section.heading && + normalizeHeadingText(section.heading).includes("signature") + ? true + : looksLikeSignatureBlock(section.content); + for (const line of section.content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/); + const rawText = bulletMatch ? bulletMatch[1].trim() : trimmed; + const manualList = parseManualListMarker(rawText); + const numeric = stripManualNumbering(rawText); + const text = bulletMatch + ? rawText + : manualList.levelOffset !== null + ? manualList.text + : numeric.text; + const inferredLevel = + currentClauseLevel === null || contentIsSignatureBlock + ? undefined + : bulletMatch + ? currentClauseLevel + 2 + : manualList.levelOffset !== null + ? currentClauseLevel + manualList.levelOffset + : numeric.levelFromPrefix !== null + ? numeric.levelFromPrefix + : numberedBodyParagraphs === 0 + ? currentClauseLevel + 1 + : currentClauseLevel + 2; + if (currentClauseLevel !== null) numberedBodyParagraphs++; + children.push( + new Paragraph({ + numbering: + inferredLevel === undefined + ? undefined + : legalNumbering(inferredLevel), + spacing: { after: 120 }, + children: [ + new TextRun({ + text, + font: FONT, + size: SIZE, + }), + ], + }), + ); + } + } + } + + const pageSetup = options?.landscape + ? { page: { size: { orientation: PageOrientation.LANDSCAPE } } } + : {}; + + const doc = new Document({ + numbering: { + config: [ + { + reference: LEGAL_NUMBERING_REF, + levels: legalNumberingLevels, + }, + ], + }, + sections: [{ properties: pageSetup, children }], + }); + const buf = await Packer.toBuffer(doc); + const zip = await import("jszip"); + const packageZip = await zip.default.loadAsync(buf); + for (const requiredPath of [ + "[Content_Types].xml", + "word/document.xml", + "word/_rels/document.xml.rels", + ]) { + if (!packageZip.file(requiredPath)) { + return { + error: `Generated DOCX is missing required package part: ${requiredPath}`, + }; + } + } + const docId = crypto.randomUUID().replace(/-/g, ""); + const safeTitle = + title + .replace(/[^a-zA-Z0-9 -]/g, "") + .trim() + .slice(0, 64) || "document"; + const filename = `${safeTitle}.docx`; + const key = generatedDocKey(userId, docId, filename); + + await uploadFile( + key, + buf.buffer as ArrayBuffer, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + const downloadUrl = buildDownloadUrl(key, filename); + + // Persist to DB so generated docs are first-class documents: + // openable in the DocPanel and editable via edit_document. In + // project chats we attach to the project so it appears in the + // sidebar; in the general chat we leave project_id null and it + // stays a standalone document. + const { data: docRow, error: docErr } = await db + .from("documents") + .insert({ + project_id: options?.projectId ?? null, + user_id: userId, + status: "ready", + }) + .select("id") + .single(); + if (docErr || !docRow) { + return { + error: `Failed to record generated document: ${docErr?.message ?? "unknown"}`, + }; + } + const documentId = docRow.id as string; + + const { data: versionRow, error: verErr } = await db + .from("document_versions") + .insert({ + document_id: documentId, + storage_path: key, + source: "generated", + version_number: 1, + filename: filename, + file_type: "docx", + size_bytes: buf.byteLength, + page_count: null, + }) + .select("id") + .single(); + if (verErr || !versionRow) { + return { + error: `Failed to record generated document version: ${verErr?.message ?? "unknown"}`, + }; + } + const versionId = versionRow.id as string; + + await db + .from("documents") + .update({ + current_version_id: versionId, + }) + .eq("id", documentId); + + return { + filename, + download_url: downloadUrl, + document_id: documentId, + version_id: versionId, + version_number: 1, + storage_path: key, + message: `Document '${filename}' has been generated successfully.`, + }; + } catch (e) { + return { error: String(e) }; + } +} + +export function safeGeneratedFilename(title: string, extension: string) { + const rawTitle = typeof title === "string" ? title : "document"; + const safeTitle = + rawTitle + .replace(/[^a-zA-Z0-9 -]/g, "") + .trim() + .slice(0, 64) || "document"; + return `${safeTitle}.${extension}`; +} + +function xmlEscape(value: unknown) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function excelColumnName(index: number) { + let n = index + 1; + let name = ""; + while (n > 0) { + const mod = (n - 1) % 26; + name = String.fromCharCode(65 + mod) + name; + n = Math.floor((n - mod) / 26); + } + return name; +} + +function normalizeSheetName(value: unknown, fallback: string) { + const raw = typeof value === "string" && value.trim() ? value.trim() : fallback; + return raw.replace(/[:\\/?*[\]]/g, " ").trim().slice(0, 31) || fallback; +} + +function normalizeRows(rows: unknown, colCount: number) { + if (!Array.isArray(rows)) return []; + return rows + .filter((row): row is unknown[] => Array.isArray(row)) + .map((row) => + Array.from({ length: colCount }, (_, i) => + row[i] == null ? "" : String(row[i]), + ), + ); +} + +async function buildXlsxWorkbook(title: string, sheetsInput: unknown[]) { + const JSZip = (await import("jszip")).default; + const zip = new JSZip(); + const sheets = sheetsInput.length ? sheetsInput : [{ name: title, columns: [], rows: [] }]; + + const normalizedSheets = sheets.map((sheet, index) => { + const raw = (sheet && typeof sheet === "object" ? sheet : {}) as { + name?: unknown; + columns?: unknown; + rows?: unknown; + }; + const columns = Array.isArray(raw.columns) + ? raw.columns.map((col) => String(col ?? "")).filter((col) => col.trim()) + : []; + const fallbackColumns = columns.length ? columns : ["Value"]; + return { + name: normalizeSheetName(raw.name, `Sheet ${index + 1}`), + columns: fallbackColumns, + rows: normalizeRows(raw.rows, fallbackColumns.length), + }; + }); + + zip.file( + "[Content_Types].xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> + <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> + <Default Extension="xml" ContentType="application/xml"/> + <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/> + <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/> + <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/> +${normalizedSheets + .map( + (_, i) => + ` <Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`, + ) + .join("\n")} +</Types>`, + ); + zip.file( + "_rels/.rels", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> + <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/> + <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/> + <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/> +</Relationships>`, + ); + zip.file( + "docProps/core.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <dc:title>${xmlEscape(title)}</dc:title> + <dc:creator>Mike</dc:creator> + <cp:lastModifiedBy>Mike</cp:lastModifiedBy> + <dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:created> + <dcterms:modified xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:modified> +</cp:coreProperties>`, + ); + zip.file( + "docProps/app.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"> + <Application>Mike</Application> +</Properties>`, + ); + zip.file( + "xl/workbook.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> + <sheets> +${normalizedSheets + .map( + (sheet, i) => + ` <sheet name="${xmlEscape(sheet.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`, + ) + .join("\n")} + </sheets> +</workbook>`, + ); + zip.file( + "xl/_rels/workbook.xml.rels", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> +${normalizedSheets + .map( + (_, i) => + ` <Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`, + ) + .join("\n")} +</Relationships>`, + ); + + for (const [sheetIndex, sheet] of normalizedSheets.entries()) { + const allRows = [sheet.columns, ...sheet.rows]; + const rowXml = allRows + .map((row, rowIndex) => { + const rowNumber = rowIndex + 1; + const cellXml = row + .map((value, colIndex) => { + const ref = `${excelColumnName(colIndex)}${rowNumber}`; + return `<c r="${ref}" t="inlineStr"><is><t>${xmlEscape(value)}</t></is></c>`; + }) + .join(""); + return `<row r="${rowNumber}">${cellXml}</row>`; + }) + .join(""); + const lastRef = `${excelColumnName(Math.max(sheet.columns.length - 1, 0))}${Math.max(allRows.length, 1)}`; + zip.file( + `xl/worksheets/sheet${sheetIndex + 1}.xml`, + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> + <dimension ref="A1:${lastRef}"/> + <sheetData>${rowXml}</sheetData> +</worksheet>`, + ); + } + + return zip.generateAsync({ type: "nodebuffer" }); +} + +function pptTextParagraphs(lines: string[], opts: { title?: boolean } = {}) { + return lines + .map((line, index) => { + const escaped = xmlEscape(line); + const titleAttrs = opts.title ? ' sz="3200" b="1"' : ' sz="2000"'; + const bullet = !opts.title && index >= 0 + ? '<a:pPr marL="342900" indent="-171450"><a:buChar char="•"/></a:pPr>' + : ""; + return `<a:p>${bullet}<a:r><a:rPr lang="en-US"${titleAttrs}/><a:t>${escaped}</a:t></a:r></a:p>`; + }) + .join(""); +} + +function pptShape(id: number, name: string, x: number, y: number, cx: number, cy: number, body: string) { + return `<p:sp> + <p:nvSpPr><p:cNvPr id="${id}" name="${xmlEscape(name)}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr> + <p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr> + <p:txBody><a:bodyPr wrap="square"/><a:lstStyle/>${body}</p:txBody> +</p:sp>`; +} + +async function buildPptxPresentation(title: string, slidesInput: unknown[]) { + const JSZip = (await import("jszip")).default; + const zip = new JSZip(); + const rawSlides = slidesInput.length + ? slidesInput + : [{ title, bullets: ["Generated by Mike"] }]; + const slides = rawSlides.map((slide, index) => { + const raw = (slide && typeof slide === "object" ? slide : {}) as { + title?: unknown; + bullets?: unknown; + }; + return { + title: + typeof raw.title === "string" && raw.title.trim() + ? raw.title.trim() + : index === 0 + ? title + : `Slide ${index + 1}`, + bullets: Array.isArray(raw.bullets) + ? raw.bullets.map((bullet) => String(bullet ?? "")).filter(Boolean) + : [], + }; + }); + + zip.file( + "[Content_Types].xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> + <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> + <Default Extension="xml" ContentType="application/xml"/> + <Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/> + <Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/> + <Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/> + <Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/> + <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/> + <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/> +${slides + .map( + (_, i) => + ` <Override PartName="/ppt/slides/slide${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`, + ) + .join("\n")} +</Types>`, + ); + zip.file( + "_rels/.rels", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> + <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/> + <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/> + <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/> +</Relationships>`, + ); + zip.file( + "docProps/core.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <dc:title>${xmlEscape(title)}</dc:title> + <dc:creator>Mike</dc:creator> + <cp:lastModifiedBy>Mike</cp:lastModifiedBy> + <dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:created> + <dcterms:modified xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:modified> +</cp:coreProperties>`, + ); + zip.file( + "docProps/app.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"> + <Application>Mike</Application> + <PresentationFormat>On-screen Show (16:9)</PresentationFormat> + <Slides>${slides.length}</Slides> +</Properties>`, + ); + zip.file( + "ppt/presentation.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"> + <p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId${slides.length + 1}"/></p:sldMasterIdLst> + <p:sldIdLst> +${slides.map((_, i) => ` <p:sldId id="${256 + i}" r:id="rId${i + 1}"/>`).join("\n")} + </p:sldIdLst> + <p:sldSz cx="12192000" cy="6858000" type="screen16x9"/> + <p:notesSz cx="6858000" cy="9144000"/> +</p:presentation>`, + ); + zip.file( + "ppt/_rels/presentation.xml.rels", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> +${slides + .map( + (_, i) => + ` <Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide${i + 1}.xml"/>`, + ) + .join("\n")} + <Relationship Id="rId${slides.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/> + <Relationship Id="rId${slides.length + 2}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/> +</Relationships>`, + ); + zip.file( + "ppt/slideMasters/slideMaster1.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"> + <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld> + <p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst> +</p:sldMaster>`, + ); + zip.file( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> + <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/> + <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/> +</Relationships>`, + ); + zip.file( + "ppt/slideLayouts/slideLayout1.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank"> + <p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld> +</p:sldLayout>`, + ); + zip.file( + "ppt/theme/theme1.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Mike"> + <a:themeElements> + <a:clrScheme name="Office"><a:dk1><a:srgbClr val="111111"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F2937"/></a:dk2><a:lt2><a:srgbClr val="F8FAFC"/></a:lt2><a:accent1><a:srgbClr val="2563EB"/></a:accent1><a:accent2><a:srgbClr val="059669"/></a:accent2><a:accent3><a:srgbClr val="D97706"/></a:accent3><a:accent4><a:srgbClr val="7C3AED"/></a:accent4><a:accent5><a:srgbClr val="DC2626"/></a:accent5><a:accent6><a:srgbClr val="0891B2"/></a:accent6><a:hlink><a:srgbClr val="2563EB"/></a:hlink><a:folHlink><a:srgbClr val="7C3AED"/></a:folHlink></a:clrScheme> + <a:fontScheme name="Office"><a:majorFont><a:latin typeface="Aptos Display"/></a:majorFont><a:minorFont><a:latin typeface="Aptos"/></a:minorFont></a:fontScheme> + <a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="6350" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme> + </a:themeElements> +</a:theme>`, + ); + + for (const [index, slide] of slides.entries()) { + const bullets = slide.bullets.length ? slide.bullets : [""]; + zip.file( + `ppt/slides/slide${index + 1}.xml`, + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"> + <p:cSld> + <p:bg><p:bgPr><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></p:bgPr></p:bg> + <p:spTree> + <p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr> + <p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr> + ${pptShape(2, "Title", 685800, 457200, 10820400, 914400, pptTextParagraphs([slide.title], { title: true }))} + ${pptShape(3, "Content", 914400, 1600200, 10363200, 4343400, pptTextParagraphs(bullets))} + </p:spTree> + </p:cSld> +</p:sld>`, + ); + zip.file( + `ppt/slides/_rels/slide${index + 1}.xml.rels`, + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> + <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/> +</Relationships>`, + ); + } + + return zip.generateAsync({ type: "nodebuffer" }); +} + +async function persistGeneratedFile(params: { + title: string; + extension: "xlsx" | "pptx"; + buffer: Buffer; + userId: string; + db: ReturnType<typeof createServerSupabase>; + projectId?: string | null; +}) { + const { title, extension, buffer, userId, db, projectId } = params; + const docId = crypto.randomUUID().replace(/-/g, ""); + const filename = safeGeneratedFilename(title, extension); + const key = generatedDocKey(userId, docId, filename); + await uploadFile( + key, + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ) as ArrayBuffer, + contentTypeForDocumentType(extension), + ); + + let pdfStoragePath: string | null = null; + if (shouldConvertToPdf(extension)) { + try { + const pdfBuf = await docxToPdf(buffer); + const pdfKey = convertedPdfKey(userId, docId); + await uploadFile( + pdfKey, + pdfBuf.buffer.slice( + pdfBuf.byteOffset, + pdfBuf.byteOffset + pdfBuf.byteLength, + ) as ArrayBuffer, + "application/pdf", + ); + pdfStoragePath = pdfKey; + } catch (err) { + devLog(`[generate_${extension}] Office→PDF conversion failed:`, err); + } + } + + const downloadUrl = buildDownloadUrl(key, filename); + const { data: docRow, error: docErr } = await db + .from("documents") + .insert({ + project_id: projectId ?? null, + user_id: userId, + status: "ready", + }) + .select("id") + .single(); + if (docErr || !docRow) { + return { + error: `Failed to record generated document: ${docErr?.message ?? "unknown"}`, + }; + } + const documentId = docRow.id as string; + + const { data: versionRow, error: verErr } = await db + .from("document_versions") + .insert({ + document_id: documentId, + storage_path: key, + pdf_storage_path: pdfStoragePath, + source: "generated", + version_number: 1, + filename, + file_type: extension, + size_bytes: buffer.byteLength, + page_count: null, + }) + .select("id") + .single(); + if (verErr || !versionRow) { + return { + error: `Failed to record generated document version: ${verErr?.message ?? "unknown"}`, + }; + } + const versionId = versionRow.id as string; + + await db + .from("documents") + .update({ current_version_id: versionId }) + .eq("id", documentId); + + return { + filename, + download_url: downloadUrl, + document_id: documentId, + version_id: versionId, + version_number: 1, + storage_path: key, + message: `Document '${filename}' has been generated successfully.`, + }; +} + +export async function generateExcel( + title: string, + sheets: unknown[], + userId: string, + db: ReturnType<typeof createServerSupabase>, + options?: { projectId?: string | null }, +) { + try { + const normalizedTitle = typeof title === "string" ? title : "Workbook"; + const buffer = await buildXlsxWorkbook( + normalizedTitle, + Array.isArray(sheets) ? sheets : [], + ); + return persistGeneratedFile({ + title: normalizedTitle, + extension: "xlsx", + buffer, + userId, + db, + projectId: options?.projectId ?? null, + }); + } catch (e) { + return { error: String(e) }; + } +} + +export async function generatePpt( + title: string, + slides: unknown[], + userId: string, + db: ReturnType<typeof createServerSupabase>, + options?: { projectId?: string | null }, +) { + try { + const normalizedTitle = typeof title === "string" ? title : "Presentation"; + const buffer = await buildPptxPresentation( + normalizedTitle, + Array.isArray(slides) ? slides : [], + ); + return persistGeneratedFile({ + title: normalizedTitle, + extension: "pptx", + buffer, + userId, + db, + projectId: options?.projectId ?? null, + }); + } catch (e) { + return { error: String(e) }; + } +} + +// --------------------------------------------------------------------------- +// Document version helpers (DOCX tracked-change editing) +// --------------------------------------------------------------------------- + +/** + * Resolve the current .docx bytes for a document, preferring the active + * tracked-changes version if one exists, else the original upload. + */ +export async function loadCurrentVersionBytes( + documentId: string, + db: ReturnType<typeof createServerSupabase>, +): Promise<{ bytes: Buffer; storage_path: string } | null> { + const active = await loadActiveVersion(documentId, db); + if (!active) return null; + const raw = await downloadFile(active.storage_path); + if (!raw) return null; + return { bytes: Buffer.from(raw), storage_path: active.storage_path }; +} + +/** + * Ensure the document has a document_versions row for the current upload. + * Called before writing the first 'assistant_edit' row so the history is + * complete. Idempotent. + */ +export async function runEditDocument(params: { + documentId: string; + userId: string; + edits: EditInput[]; + db: ReturnType<typeof createServerSupabase>; + /** + * If provided, append these edits to the existing turn-scoped version + * (overwrites the file at storagePath and reuses the document_versions + * row) instead of creating a new version. Used to collapse multiple + * edit_document tool calls within a single assistant turn into one + * version. + */ + reuseVersion?: { + versionId: string; + versionNumber: number; + storagePath: string; + }; +}): Promise< + | { + ok: true; + version_id: string; + version_number: number; + storage_path: string; + download_url: string; + annotations: EditAnnotation[]; + errors: { index: number; reason: string }[]; + } + | { ok: false; error: string } +> { + const { documentId, userId, edits, db, reuseVersion } = params; + + const { data: doc } = await db + .from("documents") + .select("id") + .eq("id", documentId) + .single(); + if (!doc) return { ok: false, error: "Document not found." }; + + const activeVersion = await loadActiveVersion(documentId, db); + let versionFilename = + activeVersion?.filename?.trim() || "Untitled document"; + + const current = await loadCurrentVersionBytes(documentId, db); + if (!current) return { ok: false, error: "Could not load document bytes." }; + + const { + bytes: editedBytes, + changes, + errors, + } = await applyTrackedEdits(current.bytes, edits, { author: "Mike" }); + + if (changes.length === 0) { + return { + ok: false, + error: + errors[0]?.reason ?? + "No edits could be applied. Refine context_before/context_after and retry.", + }; + } + + const ab = editedBytes.buffer.slice( + editedBytes.byteOffset, + editedBytes.byteOffset + editedBytes.byteLength, + ) as ArrayBuffer; + + let versionRowId: string; + let newPath: string; + let nextVersionNumber: number; + + if (reuseVersion) { + // Overwrite the existing turn version's file in place. The version + // row, version_number, and current_version_id all already point here. + newPath = reuseVersion.storagePath; + versionRowId = reuseVersion.versionId; + nextVersionNumber = reuseVersion.versionNumber; + await uploadFile( + newPath, + ab, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + await db + .from("document_versions") + .update({ + file_type: "docx", + size_bytes: editedBytes.byteLength, + page_count: null, + }) + .eq("id", versionRowId); + } else { + const versionId = crypto.randomUUID().replace(/-/g, ""); + newPath = `documents/${userId}/${documentId}/edits/${versionId}.docx`; + await uploadFile( + newPath, + ab, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + + // Per-document sequential number for the new assistant_edit + // version. The counter spans upload + user_upload + assistant_edit + // so the original upload is V1 and the first assistant edit is V2. + const { data: maxRow } = await db + .from("document_versions") + .select("version_number") + .eq("document_id", documentId) + .in("source", ["upload", "user_upload", "assistant_edit"]) + .order("version_number", { ascending: false, nullsFirst: false }) + .limit(1) + .maybeSingle(); + nextVersionNumber = ((maxRow?.version_number as number | null) ?? 1) + 1; + + // Inherit the filename from the most recent prior version so + // user-applied renames carry forward through further edits. Malformed + // legacy rows without a filename get a neutral placeholder, not the + // parent document filename. We intentionally do NOT append "[Edited Vn]" + // — the version number is surfaced separately as a tag in the UI. + const { data: prevRow } = await db + .from("document_versions") + .select("filename, created_at") + .eq("document_id", documentId) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + const inheritedFilename = + (prevRow?.filename as string | null)?.trim() || "Untitled document"; + versionFilename = inheritedFilename; + + const { data: versionRow, error: verErr } = await db + .from("document_versions") + .insert({ + document_id: documentId, + storage_path: newPath, + source: "assistant_edit", + version_number: nextVersionNumber, + filename: inheritedFilename, + file_type: "docx", + size_bytes: editedBytes.byteLength, + page_count: null, + }) + .select("id") + .single(); + if (verErr || !versionRow) { + return { ok: false, error: "Failed to record document version." }; + } + versionRowId = versionRow.id as string; + } + + // Insert one row per change + const editRows = changes.map((c) => ({ + document_id: documentId, + version_id: versionRowId, + change_id: c.id, + del_w_id: c.delId ?? null, + ins_w_id: c.insId ?? null, + deleted_text: c.deletedText, + inserted_text: c.insertedText, + context_before: c.contextBefore ?? "", + context_after: c.contextAfter ?? "", + status: "pending" as const, + })); + const { data: insertedEdits, error: editsErr } = await db + .from("document_edits") + .insert(editRows) + .select( + "id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after", + ); + + if (editsErr || !insertedEdits) { + return { ok: false, error: "Failed to record edits." }; + } + + await db + .from("documents") + .update({ + current_version_id: versionRowId, + }) + .eq("id", documentId); + + const annotations: EditAnnotation[] = insertedEdits.map( + (r: { + id: string; + change_id: string; + deleted_text: string; + inserted_text: string; + context_before: string | null; + context_after: string | null; + }) => { + const src = changes.find((c) => c.id === r.change_id); + return { + kind: "edit", + edit_id: r.id, + document_id: documentId, + version_id: versionRowId, + version_number: nextVersionNumber, + change_id: r.change_id, + del_w_id: src?.delId, + ins_w_id: src?.insId, + deleted_text: r.deleted_text ?? "", + inserted_text: r.inserted_text ?? "", + context_before: r.context_before ?? "", + context_after: r.context_after ?? "", + reason: src?.reason, + status: "pending", + }; + }, + ); + + // Persistent, non-expiring permalink. The backend streams fresh bytes + // on each request, so this URL stays valid as long as the file exists. + const resolvedFilename = versionFilename.trim() || "Untitled document.docx"; + const permalink = buildDownloadUrl(newPath, resolvedFilename); + + return { + ok: true, + version_id: versionRowId, + version_number: nextVersionNumber, + storage_path: newPath, + download_url: permalink, + annotations, + errors, + }; +} + +// --------------------------------------------------------------------------- +// Tool dispatch +// --------------------------------------------------------------------------- + +export async function getTurnReadIdentity(params: { + docLabel: string; + docStore: DocStore; + docIndex?: DocIndex; + db?: ReturnType<typeof createServerSupabase>; +}): Promise<{ + key: string; + docLabel: string; + filename: string; + documentId?: string; + versionId?: string | null; + storagePath: string; +} | null> { + const { docLabel, docStore, docIndex, db } = params; + const docInfo = docStore.get(docLabel); + if (!docInfo) return null; + + const documentId = docIndex?.[docLabel]?.document_id; + if (documentId && db) { + const active = await loadActiveVersion(documentId, db); + if (active?.storage_path) { + return { + key: `${documentId}:${active.id}`, + docLabel, + filename: docInfo.filename, + documentId, + versionId: active.id, + storagePath: active.storage_path, + }; + } + } + + return { + key: `${documentId ?? docLabel}:${docInfo.storage_path}`, + docLabel, + filename: docInfo.filename, + documentId, + versionId: docIndex?.[docLabel]?.version_id ?? null, + storagePath: docInfo.storage_path, + }; +} + +export function duplicateReadDocumentResult(identity: { + docLabel: string; + filename: string; + documentId?: string; + versionId?: string | null; +}) { + return JSON.stringify({ + ok: true, + already_read: true, + doc_id: identity.docLabel, + filename: identity.filename, + document_id: identity.documentId, + version_id: identity.versionId ?? null, + content: + "This document/version was already read earlier in this response. The full text is not repeated to avoid unnecessary token use.", + next_required_action: + "Use the prior read_document/fetch_documents result, call find_in_document for targeted checks, or proceed to edit_document.", + }); +} + +export function clearTurnReadsForDocument( + turnReadState: TurnReadState | undefined, + documentId: string, +) { + if (!turnReadState) return; + for (const [key, value] of turnReadState.entries()) { + if (value.documentId === documentId) turnReadState.delete(key); + } +} + +export async function readDocumentContent( + docLabel: string, + docStore: DocStore, + write: (s: string) => void, + docIndex?: DocIndex, + db?: ReturnType<typeof createServerSupabase>, + opts?: { emitEvents?: boolean }, +): Promise<string> { + const emitEvents = opts?.emitEvents ?? true; + devLog(`[read_document] called with docLabel="${docLabel}"`); + const docInfo = docStore.get(docLabel); + if (!docInfo) { + devLog( + `[read_document] MISS — docLabel "${docLabel}" not in docStore. Known labels:`, + Array.from(docStore.keys()), + ); + return "Document not found."; + } + devLog( + `[read_document] docInfo: filename="${docInfo.filename}", file_type="${docInfo.file_type}", storage_path="${docInfo.storage_path}"`, + ); + + const documentId = docIndex?.[docLabel]?.document_id; + const emitDocRead = () => { + if (!emitEvents) return; + write( + `data: ${JSON.stringify({ + type: "doc_read", + filename: docInfo.filename, + document_id: documentId, + })}\n\n`, + ); + }; + if (emitEvents) + write( + `data: ${JSON.stringify({ + type: "doc_read_start", + filename: docInfo.filename, + document_id: documentId, + })}\n\n`, + ); + try { + // Prefer the current tracked-changes version (if any) so read_document + // reflects accepted/pending edits rather than the original upload. + let raw: ArrayBuffer | null = null; + let sourcePath = docInfo.storage_path; + if (documentId && db) { + const current = await loadCurrentVersionBytes(documentId, db); + if (current) { + raw = current.bytes.buffer.slice( + current.bytes.byteOffset, + current.bytes.byteOffset + current.bytes.byteLength, + ) as ArrayBuffer; + sourcePath = current.storage_path; + devLog( + `[read_document] using current version path="${sourcePath}" (bytes=${raw.byteLength})`, + ); + } else { + devLog( + `[read_document] loadCurrentVersionBytes returned null for documentId="${documentId}", falling back to original storage_path`, + ); + } + } + if (!raw) { + raw = await downloadFile(docInfo.storage_path); + if (raw) { + devLog( + `[read_document] fallback download from storage_path="${docInfo.storage_path}" (bytes=${raw.byteLength})`, + ); + } + } + if (!raw) { + devLog( + `[read_document] FAILED to download any bytes for docLabel="${docLabel}" (tried path="${sourcePath}")`, + ); + emitDocRead(); + return "Document could not be read."; + } + // Log the first 8 bytes so we can identify real file format regardless + // of the declared file_type. Valid .docx starts with "PK\x03\x04" + // (zip). Legacy .doc starts with "\xD0\xCF\x11\xE0" (OLE/CFB). + // %PDF-1 is a PDF even if mislabeled. Truncated uploads show as all-zero. + { + const head = Buffer.from(raw).subarray(0, 8); + const hex = head.toString("hex"); + const ascii = head.toString("binary").replace(/[^\x20-\x7e]/g, "."); + devLog( + `[read_document] magic bytes hex=${hex} ascii="${ascii}" for filename="${docInfo.filename}"`, + ); + } + let text: string; + const fileType = docInfo.file_type?.toLowerCase?.() ?? ""; + if (fileType === "pdf") { + text = await extractPdfText(raw); + devLog( + `[read_document] pdf extracted length=${text.length} for filename="${docInfo.filename}"`, + ); + } else if (fileType === "docx") { + // Use the same flattening as the edit_document matcher so the + // LLM sees exactly the characters it can anchor against. + text = await extractDocxBodyText(Buffer.from(raw)); + devLog( + `[read_document] docx extractDocxBodyText length=${text.length} for filename="${docInfo.filename}"`, + ); + if (!text) { + devLog( + `[read_document] docx accepted-view extractor returned empty, falling back to mammoth for filename="${docInfo.filename}"`, + ); + const mammoth = await import("mammoth"); + const result = await mammoth.extractRawText({ + buffer: Buffer.from(raw), + }); + text = result.value; + devLog( + `[read_document] docx mammoth fallback length=${text.length} for filename="${docInfo.filename}"`, + ); + } + } else if (isSpreadsheetDocumentType(fileType)) { + // SheetJS reads .xlsx/.xlsm/.xls directly (no PDF detour), emitting a + // cell-addressed markdown view with Excel-formatted values. + text = spreadsheetToLLMText(Buffer.from(raw)); + devLog( + `[read_document] spreadsheet extracted length=${text.length} for filename="${docInfo.filename}"`, + ); + } else if (fileType === "pptx") { + text = await extractPresentationText(Buffer.from(raw)); + devLog( + `[read_document] presentation extracted length=${text.length} for filename="${docInfo.filename}"`, + ); + } else if ( + isPresentationDocumentType(fileType) || + isWordDocumentType(fileType) + ) { + devLog( + `[read_document] legacy Office file_type="${fileType}" for filename="${docInfo.filename}", converting to pdf for text extraction`, + ); + const pdfBuf = await docxToPdf(Buffer.from(raw)); + text = await extractPdfText( + pdfBuf.buffer.slice( + pdfBuf.byteOffset, + pdfBuf.byteOffset + pdfBuf.byteLength, + ) as ArrayBuffer, + ); + devLog( + `[read_document] legacy Office PDF extraction length=${text.length} for filename="${docInfo.filename}"`, + ); + } else { + devLog( + `[read_document] unknown file_type="${docInfo.file_type}" for filename="${docInfo.filename}", trying mammoth`, + ); + const mammoth = await import("mammoth"); + const result = await mammoth.extractRawText({ + buffer: Buffer.from(raw), + }); + text = result.value; + devLog( + `[read_document] mammoth length=${text.length} for filename="${docInfo.filename}"`, + ); + } + devLog( + `[read_document] DONE filename="${docInfo.filename}" finalTextLength=${text.length} firstChars=${JSON.stringify(text.slice(0, 120))}`, + ); + emitDocRead(); + return text; + } catch (err) { + devLog( + `[read_document] THREW for docLabel="${docLabel}" filename="${docInfo.filename}":`, + err, + ); + if (emitEvents) + write( + `data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`, + ); + return "Document could not be read."; + } +} + +/** + * Build a whitespace-collapsed, lowercased copy of `text`, plus a map from + * each character index in the normalized form back to the corresponding + * index in the original text. Used by `findInDocumentContent` so matches + * are tolerant of case + whitespace variance but can still return the + * exact original excerpt. + */ +function normalizeWithMap(text: string): { norm: string; origIdx: number[] } { + const norm: string[] = []; + const origIdx: number[] = []; + let prevSpace = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (/\s/.test(ch)) { + if (!prevSpace) { + norm.push(" "); + origIdx.push(i); + prevSpace = true; + } + } else { + norm.push(ch.toLowerCase()); + origIdx.push(i); + prevSpace = false; + } + } + return { norm: norm.join(""), origIdx }; +} + +function normalizeQuery(q: string): string { + return q.trim().replace(/\s+/g, " ").toLowerCase(); +} + +export type TextMatch = { + index: number; + excerpt: string; + context: string; +}; + +export function findTextMatches(params: { + text: string; + query: string; + maxResults: number; + contextChars: number; + startIndex?: number; +}): { hits: TextMatch[]; totalMatches: number } { + const { text, query, maxResults, contextChars, startIndex = 0 } = params; + const { norm, origIdx } = normalizeWithMap(text); + const needle = normalizeQuery(query); + const hits: TextMatch[] = []; + let totalMatches = 0; + if (!needle) return { hits, totalMatches }; + + let from = 0; + while (from <= norm.length - needle.length) { + const pos = norm.indexOf(needle, from); + if (pos < 0) break; + const endNormPos = pos + needle.length; + const origStart = origIdx[pos] ?? 0; + const origEnd = + endNormPos - 1 < origIdx.length + ? origIdx[endNormPos - 1] + 1 + : text.length; + if (hits.length < maxResults) { + const ctxStart = Math.max(0, origStart - contextChars); + const ctxEnd = Math.min(text.length, origEnd + contextChars); + hits.push({ + index: startIndex + hits.length, + excerpt: text.slice(origStart, origEnd), + context: + (ctxStart > 0 ? "…" : "") + + text.slice(ctxStart, ctxEnd).replace(/\s+/g, " ").trim() + + (ctxEnd < text.length ? "…" : ""), + }); + } + totalMatches++; + from = pos + Math.max(1, needle.length); + } + + return { hits, totalMatches }; +} + +/** + * Ctrl+F helper. Returns a JSON-serializable result with up to `maxResults` + * hits, each containing the original-text excerpt plus surrounding context. + */ +export async function findInDocumentContent(params: { + docLabel: string; + query: string; + maxResults?: number; + contextChars?: number; + docStore: DocStore; + write: (s: string) => void; + docIndex?: DocIndex; + db?: ReturnType<typeof createServerSupabase>; +}): Promise<string> { + const { + docLabel, + query, + maxResults = 20, + contextChars = 80, + docStore, + write, + docIndex, + db, + } = params; + + if (!query || !query.trim()) { + return JSON.stringify({ ok: false, error: "Empty query." }); + } + + const docInfo = docStore.get(docLabel); + if (!docInfo) { + return JSON.stringify({ + ok: false, + error: `Document '${docLabel}' not found.`, + }); + } + + // Announce the search to the UI, then reuse readDocumentContent for its + // fallbacks — but suppress its own doc_read events so the user only sees + // the doc_find block (not a competing doc_read block for the same op). + write( + `data: ${JSON.stringify({ + type: "doc_find_start", + filename: docInfo.filename, + query, + })}\n\n`, + ); + + const text = await readDocumentContent( + docLabel, + docStore, + write, + docIndex, + db, + { emitEvents: false }, + ); + if (!text || text === "Document could not be read.") { + write( + `data: ${JSON.stringify({ + type: "doc_find", + filename: docInfo.filename, + query, + total_matches: 0, + })}\n\n`, + ); + return JSON.stringify({ + ok: false, + filename: docInfo.filename, + error: "Document could not be read.", + }); + } + + const needle = normalizeQuery(query); + if (!needle) { + return JSON.stringify({ + ok: false, + error: "Empty query after normalization.", + }); + } + + const { hits, totalMatches } = findTextMatches({ + text, + query, + maxResults, + contextChars, + }); + + write( + `data: ${JSON.stringify({ + type: "doc_find", + filename: docInfo.filename, + query, + total_matches: totalMatches, + })}\n\n`, + ); + + return JSON.stringify({ + ok: true, + filename: docInfo.filename, + query, + total_matches: totalMatches, + returned: hits.length, + truncated: totalMatches > hits.length, + hits, + }); +} + +export type DocEditedResult = { + filename: string; + document_id: string; + version_id: string; + version_number: number | null; + download_url: string; + annotations: EditAnnotation[]; +}; + +export type TurnEditState = Map< + string, + { versionId: string; versionNumber: number; storagePath: string } +>; + +export type TurnReadState = Map< + string, + { + docLabel: string; + filename: string; + documentId?: string; + versionId?: string | null; + storagePath: string; + } +>; + +export type DocCreatedResult = { + filename: string; + download_url: string; + document_id?: string; + version_id?: string; + version_number?: number | null; +}; + +export type DocReplicatedResult = { + /** Filename of the source document being copied. */ + filename: string; + /** How many copies were produced in this single tool call. */ + count: number; + /** One entry per new copy. */ + copies: { + new_filename: string; + document_id: string; + version_id: string; + }[]; +}; diff --git a/backend/src/lib/chat/tools/toolDispatcher.ts b/backend/src/lib/chat/tools/toolDispatcher.ts new file mode 100644 index 0000000..3e6f674 --- /dev/null +++ b/backend/src/lib/chat/tools/toolDispatcher.ts @@ -0,0 +1,1896 @@ +import { + getCourtlistenerCases, + searchCourtlistenerCaseLaw, + verifyCourtlistenerCitations, +} from "../../courtlistener"; +import { + COURTLISTENER_TOOL_NAMES, + type CaseCitationEvent, + type CourtlistenerToolEvent, +} from "./courtlistenerTools"; +import { + executeMcpToolCall, + type McpToolEvent, +} from "../../mcpConnectors"; +import { createServerSupabase } from "../../supabase"; +import { + type DocStore, + type DocIndex, + type TabularCellStore, + type WorkflowStore, + type ToolCall, + type AskInputItem, + type AskInputOption, + type AskInputsEvent, + devLog, + resolveDocLabel, +} from "../types"; +import { + downloadFile, + storageKey, + uploadFile, +} from "../../storage"; +import { convertedPdfKey } from "../../convert"; +import { contentTypeForDocumentType } from "../../documentTypes"; +import { buildDownloadUrl } from "../../downloadTokens"; +import { loadActiveVersion } from "../../documentVersions"; +import { type EditInput } from "../../docxTrackedChanges"; +import { + citationReminder, + generateDocx, + generateExcel, + generatePpt, + getTurnReadIdentity, + duplicateReadDocumentResult, + clearTurnReadsForDocument, + readDocumentContent, + findInDocumentContent, + findTextMatches, + runEditDocument, + safeGeneratedFilename, + type DocEditedResult, + type TurnEditState, + type TurnReadState, + type DocCreatedResult, + type DocReplicatedResult, + type TextMatch, +} from "./documentOps"; + + +type CourtlistenerCaseRecord = { + clusterId: number; + caseName: string | null; + citations: string[]; + url: string | null; + pdfUrl: string | null; + dateFiled: string | null; + opinions?: unknown[]; +}; + +type CourtlistenerCaseInput = { + clusterId?: number | null; + caseName?: string | null; + citation?: string | null; + citations?: string[]; + url?: string | null; + pdfUrl?: string | null; + dateFiled?: string | null; + opinions?: unknown[]; +}; + +export type CourtlistenerTurnState = { + casesByClusterId: Map<number, CourtlistenerCaseRecord>; +}; + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function cleanAskInputString(value: unknown, fallback = ""): string { + const text = typeof value === "string" ? value.trim() : ""; + return text || fallback; +} + +function normalizeAskInputsEvent(args: Record<string, unknown>): AskInputsEvent { + const rawItems = Array.isArray(args.items) ? args.items : []; + const items = rawItems + .map((item, index): AskInputItem | null => { + if (!item || typeof item !== "object" || Array.isArray(item)) return null; + const row = item as Record<string, unknown>; + const id = + cleanAskInputString(row.id) || + `${row.kind === "documents" ? "documents" : "choice"}-${index + 1}`; + const responsePrefix = cleanAskInputString(row.response_prefix); + + if (row.kind === "documents") { + const rawDocumentTypes = Array.isArray(row.document_types) + ? row.document_types + : []; + const documentTypes = rawDocumentTypes + .filter((type): type is string => typeof type === "string") + .map((type) => type.trim()) + .filter(Boolean) + .map((type) => type.slice(0, 300)) + .slice(0, 8); + return { + id: id.slice(0, 80), + kind: "documents", + document_types: documentTypes, + ...(responsePrefix + ? { response_prefix: responsePrefix.slice(0, 200) } + : {}), + }; + } + + const question = cleanAskInputString( + row.question, + "Please choose an option.", + ); + const rawOptions = Array.isArray(row.options) ? row.options : []; + const options = rawOptions + .map((option): AskInputOption | null => { + if (!option || typeof option !== "object") return null; + const optionRow = option as Record<string, unknown>; + const value = + cleanAskInputString(optionRow.value) || + cleanAskInputString(optionRow.label); + if (!value) return null; + return { + value: value.slice(0, 500), + }; + }) + .filter((option): option is AskInputOption => !!option) + .slice(0, 8); + const normalizedOptions = + options.length > 0 ? options : [{ value: "Continue" }]; + const otherLabel = cleanAskInputString(row.other_label, "Other"); + return { + id: id.slice(0, 80), + kind: "choice", + question: question.slice(0, 500), + options: normalizedOptions, + allow_other: row.allow_other !== false, + other_label: otherLabel.slice(0, 80), + ...(responsePrefix + ? { response_prefix: responsePrefix.slice(0, 200) } + : {}), + }; + }) + .filter((item): item is AskInputItem => !!item) + .slice(0, 12); + + return { type: "ask_inputs", items }; +} + +function upsertCourtlistenerCases( + state: CourtlistenerTurnState, + inputs: CourtlistenerCaseInput[], +): CourtlistenerCaseRecord[] { + const records: CourtlistenerCaseRecord[] = []; + for (const input of inputs) { + if (typeof input.clusterId !== "number" || !Number.isFinite(input.clusterId)) { + continue; + } + const clusterId = Math.floor(input.clusterId); + const current = + state.casesByClusterId.get(clusterId) ?? + { + clusterId, + caseName: null, + citations: [], + url: null, + pdfUrl: null, + dateFiled: null, + }; + const nextCitations = [ + ...current.citations, + ...(input.citation ? [input.citation] : []), + ...(input.citations ?? []), + ] + .map(nonEmpty) + .filter((value): value is string => !!value); + const record: CourtlistenerCaseRecord = { + ...current, + caseName: current.caseName ?? nonEmpty(input.caseName), + citations: Array.from(new Set(nextCitations)), + url: current.url ?? nonEmpty(input.url), + pdfUrl: current.pdfUrl ?? nonEmpty(input.pdfUrl), + dateFiled: current.dateFiled ?? nonEmpty(input.dateFiled), + opinions: current.opinions ?? input.opinions, + }; + state.casesByClusterId.set(clusterId, record); + records.push(record); + } + return records; +} + +function caseCitationEventFromRecord( + record: CourtlistenerCaseRecord, +): CaseCitationEvent | null { + if (!record.url) return null; + return { + type: "case_citation", + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + url: record.url, + pdfUrl: record.pdfUrl, + dateFiled: record.dateFiled, + }; +} + +function recordFromUnknown(value: unknown): Record<string, unknown> | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record<string, unknown>) + : null; +} + +function stringField( + record: Record<string, unknown> | null, + key: string, +): string | null { + const value = record?.[key]; + return typeof value === "string" ? value : null; +} + +function numberField( + record: Record<string, unknown> | null, + key: string, +): number | null { + const value = record?.[key]; + return typeof value === "number" && Number.isFinite(value) + ? Math.floor(value) + : null; +} + +function stringArrayField( + record: Record<string, unknown> | null, + key: string, +): string[] { + const value = record?.[key]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function courtlistenerCaseInputFromFetchedCase( + fallbackClusterId: number, + fetchedCase: unknown, +): CourtlistenerCaseInput { + const record = recordFromUnknown(fetchedCase); + const clusterId = + numberField(record, "clusterId") ?? numberField(record, "id") ?? fallbackClusterId; + return { + clusterId, + caseName: stringField(record, "caseName"), + citations: stringArrayField(record, "citations"), + url: stringField(record, "url"), + pdfUrl: stringField(record, "pdfUrl"), + dateFiled: stringField(record, "dateFiled"), + opinions: Array.isArray(record?.opinions) ? record.opinions : undefined, + }; +} + +function courtlistenerOpinionCount(fetchedCase: unknown): number { + const record = recordFromUnknown(fetchedCase); + return Array.isArray(record?.opinions) ? record.opinions.length : 0; +} + +function courtlistenerOpinionMetadata(raw: unknown) { + const opinion = recordFromUnknown(raw); + if (!opinion) return null; + const text = + stringField(opinion, "text") ?? + (stringField(opinion, "html") + ? stripCaseOpinionHtml(stringField(opinion, "html")!) + : null); + return { + opinion_id: + numberField(opinion, "opinionId") ?? numberField(opinion, "id"), + type: stringField(opinion, "type"), + author: stringField(opinion, "author"), + per_curiam: stringField(opinion, "per_curiam"), + joined_by_str: stringField(opinion, "joined_by_str"), + url: stringField(opinion, "url"), + char_count: text?.length ?? 0, + }; +} + +function courtlistenerFetchedCaseMetadata( + record: CourtlistenerCaseRecord, + opinionCount: number, +) { + return { + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + citations: record.citations, + dateFiled: record.dateFiled, + url: record.url, + pdfUrl: record.pdfUrl, + opinion_count: opinionCount, + opinions: (record.opinions ?? []) + .map(courtlistenerOpinionMetadata) + .filter((opinion): opinion is NonNullable<typeof opinion> => !!opinion), + }; +} + +function stripCaseOpinionHtml(value: string): string { + return value + .replace(/<style[\s\S]*?<\/style>/gi, " ") + .replace(/<script[\s\S]*?<\/script>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, " ") + .trim(); +} + +type CachedCaseOpinionText = { + opinion_id: number | null; + type: string | null; + author: string | null; + url: string | null; + text: string; +}; + +function cachedCaseOpinionTexts( + record: CourtlistenerCaseRecord, +): CachedCaseOpinionText[] { + return (record.opinions ?? []) + .map((raw) => { + const opinion = recordFromUnknown(raw); + if (!opinion) return null; + const text = + stringField(opinion, "text") ?? + (stringField(opinion, "html") + ? stripCaseOpinionHtml(stringField(opinion, "html")!) + : null); + if (!text) return null; + return { + opinion_id: + numberField(opinion, "opinionId") ?? numberField(opinion, "id"), + type: stringField(opinion, "type"), + author: stringField(opinion, "author"), + url: stringField(opinion, "url"), + text, + }; + }) + .filter((opinion): opinion is CachedCaseOpinionText => !!opinion); +} + +function requestedCourtlistenerOpinionIds(args: Record<string, unknown>) { + const rawIds = Array.isArray(args.opinionIds) + ? args.opinionIds + : Array.isArray(args.opinion_ids) + ? args.opinion_ids + : typeof args.opinionId === "number" + ? [args.opinionId] + : typeof args.opinion_id === "number" + ? [args.opinion_id] + : []; + return Array.from( + new Set( + rawIds + .filter((value): value is number => typeof value === "number") + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.floor(value)), + ), + ); +} + +type FindInCaseArgs = { + clusterId: number | null; + query: string; + maxResults: number; + contextChars: number; +}; + +function parseFindInCaseArgs(args: Record<string, unknown>): FindInCaseArgs { + return { + clusterId: + typeof args.clusterId === "number" && Number.isFinite(args.clusterId) + ? Math.floor(args.clusterId) + : typeof args.cluster_id === "number" && Number.isFinite(args.cluster_id) + ? Math.floor(args.cluster_id) + : null, + query: typeof args.query === "string" ? args.query : "", + maxResults: + typeof args.max_results === "number" + ? Math.max(0, Math.floor(args.max_results)) + : 20, + contextChars: + typeof args.context_chars === "number" + ? Math.max(0, Math.floor(args.context_chars)) + : 160, + }; +} + +function findInCaseSearchSummary( + event: Extract<CourtlistenerToolEvent, { type: "courtlistener_find_in_case" }>, +) { + return { + cluster_id: event.cluster_id, + query: event.query, + total_matches: event.total_matches, + case_name: event.case_name, + citation: event.citation, + error: event.error, + }; +} + +function cachedCaseNotFetchedResult(clusterId: number | null) { + return { + ok: false, + cluster_id: clusterId, + error: + "Case has not been fetched in this turn. Call courtlistener_get_cases first.", + }; +} + +export async function runToolCalls( + toolCalls: ToolCall[], + docStore: DocStore, + userId: string, + db: ReturnType<typeof createServerSupabase>, + write: (s: string) => void, + workflowStore?: WorkflowStore, + tabularStore?: TabularCellStore, + docIndex?: DocIndex, + turnEditState?: TurnEditState, + turnReadState?: TurnReadState, + projectId?: string | null, + courtlistenerState?: CourtlistenerTurnState, + apiKeys?: import("../../llm").UserApiKeys, +): Promise<{ + toolResults: unknown[]; + docsRead: { filename: string; document_id?: string }[]; + docsFound: { filename: string; query: string; total_matches: number }[]; + docsCreated: DocCreatedResult[]; + docsReplicated: DocReplicatedResult[]; + workflowsApplied: { workflow_id: string; title: string }[]; + docsEdited: DocEditedResult[]; + askInputsEvents: AskInputsEvent[]; + courtlistenerEvents: CourtlistenerToolEvent[]; + caseCitationEvents: CaseCitationEvent[]; + mcpEvents: McpToolEvent[]; +}> { + const toolResults: unknown[] = []; + const docsRead: { filename: string; document_id?: string }[] = []; + const docsFound: { + filename: string; + query: string; + total_matches: number; + }[] = []; + const docsCreated: DocCreatedResult[] = []; + const docsReplicated: DocReplicatedResult[] = []; + const workflowsApplied: { workflow_id: string; title: string }[] = []; + const docsEdited: DocEditedResult[] = []; + const askInputsEvents: AskInputsEvent[] = []; + const courtlistenerEvents: CourtlistenerToolEvent[] = []; + const caseCitationEvents: CaseCitationEvent[] = []; + const mcpEvents: McpToolEvent[] = []; + const courtState: CourtlistenerTurnState = + courtlistenerState ?? + { + casesByClusterId: new Map(), + }; + const groupedFindInCaseSearches = toolCalls + .filter((tc) => tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase) + .map((tc) => { + let rawArgs: Record<string, unknown> = {}; + try { + rawArgs = JSON.parse(tc.function.arguments || "{}"); + } catch { + /* ignore */ + } + const parsed = parseFindInCaseArgs(rawArgs); + return { + cluster_id: parsed.clusterId, + query: parsed.query, + total_matches: 0, + }; + }); + const shouldGroupFindInCase = groupedFindInCaseSearches.length > 1; + let groupedFindInCaseStarted = false; + const groupedFindInCaseEvents: Extract< + CourtlistenerToolEvent, + { type: "courtlistener_find_in_case" } + >[] = []; + + const registerGeneratedDocument = ( + tc: ToolCall, + result: Record<string, unknown>, + previewFilename: string, + fileType: string, + ) => { + let newDocLabel: string | null = null; + if ("filename" in result && "download_url" in result) { + const dlFilename = result.filename as string; + const dlUrl = result.download_url as string; + const documentId = (result as { document_id?: string }).document_id; + const versionId = (result as { version_id?: string }).version_id; + const versionNumber = + (result as { version_number?: number }).version_number ?? null; + const storagePath = (result as { storage_path?: string }).storage_path; + + if (documentId && storagePath && docIndex) { + const existingLabels = new Set(Object.keys(docIndex)); + let i = 0; + while (existingLabels.has(`doc-${i}`)) i++; + newDocLabel = `doc-${i}`; + docIndex[newDocLabel] = { + document_id: documentId, + filename: dlFilename, + }; + docStore.set(newDocLabel, { + storage_path: storagePath, + file_type: fileType, + filename: dlFilename, + }); + } + + write( + `data: ${JSON.stringify({ + type: "doc_created", + filename: dlFilename, + download_url: dlUrl, + document_id: documentId, + version_id: versionId, + version_number: versionNumber, + })}\n\n`, + ); + docsCreated.push({ + filename: dlFilename, + download_url: dlUrl, + document_id: documentId, + version_id: versionId, + version_number: versionNumber, + }); + } else { + write( + `data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`, + ); + } + + const { download_url, storage_path, ...safeToolResult } = result; + const toolResultPayload = newDocLabel + ? { + ...safeToolResult, + doc_id: newDocLabel, + next_required_action: [ + `Before writing your final response, call read_document with doc_id "${newDocLabel}".`, + `Base your description on the generated document's actual returned text, not on memory of what you intended to generate.`, + `Do not include download links, URLs, or markdown links to the document in your prose response; the document card is shown automatically by the UI.`, + `Give a concise description of the generated document and, if you make factual claims about its contents, cite it with [N] markers and a final <CITATIONS> block using doc_id "${newDocLabel}", not any source/template document.`, + ].join(" "), + } + : safeToolResult; + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(toolResultPayload), + }); + }; + + for (const tc of toolCalls) { + let args: Record<string, unknown> = {}; + try { + args = JSON.parse(tc.function.arguments || "{}"); + } catch { + /* ignore */ + } + + if (tc.function.name.startsWith("mcp_")) { + write( + `data: ${JSON.stringify({ + type: "mcp_tool_start", + name: tc.function.name, + })}\n\n`, + ); + const { content, event } = await executeMcpToolCall( + userId, + tc.function.name, + args, + db, + ); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content, + }); + mcpEvents.push(event); + write( + `data: ${JSON.stringify({ + type: "mcp_tool_result", + name: tc.function.name, + connector_name: event.connector_name, + tool_name: event.tool_name, + status: event.status, + error: event.error, + })}\n\n`, + ); + continue; + } + + if (tc.function.name === "ask_inputs") { + const event = normalizeAskInputsEvent(args); + if (event.items.length > 0) askInputsEvents.push(event); + continue; + } + + if (tc.function.name === "read_document") { + const rawDocId = args.doc_id as string; + const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; + const readIdentity = await getTurnReadIdentity({ + docLabel: docId, + docStore, + docIndex, + db, + }); + if (readIdentity && turnReadState?.has(readIdentity.key)) { + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: duplicateReadDocumentResult(readIdentity), + }); + continue; + } + const content = await readDocumentContent( + docId, + docStore, + write, + docIndex, + db, + ); + const filename = docStore.get(docId)?.filename; + const documentId = docIndex?.[docId]?.document_id; + if (readIdentity && turnReadState) { + turnReadState.set(readIdentity.key, readIdentity); + } + if (filename) docsRead.push({ filename, document_id: documentId }); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: filename + ? `${citationReminder(docId, filename)}\n\n${content}` + : content, + }); + } else if (tc.function.name === "find_in_document") { + const rawDocId = args.doc_id as string; + const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; + const query = (args.query as string) ?? ""; + const maxResults = + typeof args.max_results === "number" ? args.max_results : undefined; + const contextChars = + typeof args.context_chars === "number" ? args.context_chars : undefined; + const content = await findInDocumentContent({ + docLabel: docId, + query, + maxResults, + contextChars, + docStore, + write, + docIndex, + db, + }); + const filename = docStore.get(docId)?.filename; + if (filename) { + let totalMatches = 0; + try { + const parsed = JSON.parse(content) as { + total_matches?: number; + }; + totalMatches = parsed.total_matches ?? 0; + } catch { + /* ignore — still record the find attempt */ + } + docsFound.push({ + filename, + query, + total_matches: totalMatches, + }); + } + toolResults.push({ role: "tool", tool_call_id: tc.id, content }); + } else if (tc.function.name === "list_documents") { + const list = Array.from(docStore.entries()).map(([doc_id, info]) => ({ + doc_id, + filename: info.filename, + file_type: info.file_type, + })); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(list), + }); + } else if (tc.function.name === "fetch_documents") { + const rawDocIds = (args.doc_ids as string[]) ?? []; + const docIds = rawDocIds.map( + (id) => resolveDocLabel(id, docStore, docIndex) ?? id, + ); + const parts: string[] = []; + for (const docId of docIds) { + const readIdentity = await getTurnReadIdentity({ + docLabel: docId, + docStore, + docIndex, + db, + }); + if (readIdentity && turnReadState?.has(readIdentity.key)) { + const filename = docStore.get(docId)?.filename ?? docId; + parts.push( + `--- ${filename} (${docId}) ---\n${duplicateReadDocumentResult( + readIdentity, + )}`, + ); + continue; + } + const content = await readDocumentContent( + docId, + docStore, + write, + docIndex, + db, + ); + const filename = docStore.get(docId)?.filename ?? docId; + if (readIdentity && turnReadState) { + turnReadState.set(readIdentity.key, readIdentity); + } + parts.push( + `--- ${filename} (${docId}) ---\n${citationReminder(docId, filename)}\n\n${content}`, + ); + if (docStore.get(docId)) { + const documentId = docIndex?.[docId]?.document_id; + docsRead.push({ filename, document_id: documentId }); + } + } + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: parts.join("\n\n"), + }); + } else if (tc.function.name === "list_workflows") { + const list = workflowStore + ? Array.from(workflowStore.entries()).map(([id, w]) => ({ + id, + title: w.title, + })) + : []; + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(list), + }); + } else if (tc.function.name === "read_workflow") { + const wfId = args.workflow_id as string; + const wf = workflowStore?.get(wfId); + if (wf) { + write( + `data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`, + ); + workflowsApplied.push({ workflow_id: wfId, title: wf.title }); + } + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: wf ? wf.skill_md : `Workflow '${wfId}' not found.`, + }); + } else if (tc.function.name === "read_table_cells" && tabularStore) { + const colIndices = args.col_indices as number[] | undefined; + const rowIndices = args.row_indices as number[] | undefined; + + const filteredCols = colIndices?.length + ? tabularStore.columns.filter((_, i) => colIndices.includes(i)) + : tabularStore.columns; + const filteredDocs = rowIndices?.length + ? tabularStore.documents.filter((_, i) => rowIndices.includes(i)) + : tabularStore.documents; + + const label = `${filteredCols.length} ${filteredCols.length === 1 ? "column" : "columns"} × ${filteredDocs.length} ${filteredDocs.length === 1 ? "row" : "rows"}`; + write( + `data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`, + ); + + const lines: string[] = []; + for (const col of filteredCols) { + const colPos = tabularStore.columns.findIndex( + (c) => c.index === col.index, + ); + for (const doc of filteredDocs) { + const rowPos = tabularStore.documents.findIndex( + (d) => d.id === doc.id, + ); + const cell = tabularStore.cells.get(`${col.index}:${doc.id}`); + lines.push( + `[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`, + ); + if (cell?.summary) { + lines.push(`Summary: ${cell.summary}`); + if (cell.flag) lines.push(`Flag: ${cell.flag}`); + if (cell.reasoning) lines.push(`Reasoning: ${cell.reasoning}`); + } else { + lines.push(`(not yet generated)`); + } + lines.push(""); + } + } + + write( + `data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`, + ); + docsRead.push({ filename: label }); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: lines.join("\n") || "No cells found.", + }); + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.searchCaseLaw) { + const query = typeof args.query === "string" ? args.query : ""; + write( + `data: ${JSON.stringify({ type: "courtlistener_search_case_law_start", query })}\n\n`, + ); + try { + const result = await searchCourtlistenerCaseLaw({ + query: query || undefined, + court: typeof args.court === "string" ? args.court : undefined, + filedAfter: + typeof args.filedAfter === "string" ? args.filedAfter : undefined, + filedBefore: + typeof args.filedBefore === "string" ? args.filedBefore : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined, + apiToken: apiKeys?.courtlistener, + }); + const resultCount = + result && + typeof result === "object" && + Array.isArray((result as { results?: unknown }).results) + ? (result as { results: unknown[] }).results.length + : 0; + const error = + result && + typeof result === "object" && + typeof (result as { error?: unknown }).error === "string" + ? (result as { error: string }).error + : undefined; + const event: CourtlistenerToolEvent = { + type: "courtlistener_search_case_law", + query, + result_count: resultCount, + ...(error ? { error } : {}), + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(result), + }); + } catch (err) { + const event: CourtlistenerToolEvent = { + type: "courtlistener_search_case_law", + query, + result_count: 0, + error: + err instanceof Error ? err.message : "CourtListener search failed.", + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + error: + err instanceof Error + ? err.message + : "CourtListener search failed.", + }), + }); + } + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.getCases) { + const rawClusterIds = Array.isArray(args.clusterIds) + ? args.clusterIds + : Array.isArray(args.cluster_ids) + ? args.cluster_ids + : typeof args.clusterId === "number" + ? [args.clusterId] + : []; + const clusterIds = Array.from( + new Set( + rawClusterIds + .filter((value): value is number => typeof value === "number") + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.floor(value)), + ), + ); + write( + `data: ${JSON.stringify({ type: "courtlistener_get_cases_start", cluster_ids: clusterIds })}\n\n`, + ); + try { + const result = await getCourtlistenerCases({ + clusterIds, + db, + apiToken: apiKeys?.courtlistener, + }); + const fetchedCases = + result && + typeof result === "object" && + Array.isArray((result as { cases?: unknown }).cases) + ? (result as { cases: unknown[] }).cases + : []; + fetchedCases.forEach((fetchedCase, index) => { + const clusterId = + courtlistenerCaseInputFromFetchedCase( + clusterIds[index] ?? 0, + fetchedCase, + ).clusterId ?? 0; + if (clusterId) { + write( + `data: ${JSON.stringify({ type: "case_opinions", cluster_id: clusterId, case: fetchedCase })}\n\n`, + ); + } + }); + const caseRecords = upsertCourtlistenerCases( + courtState, + fetchedCases.map((fetchedCase, index) => + courtlistenerCaseInputFromFetchedCase( + clusterIds[index] ?? 0, + fetchedCase, + ), + ), + ); + const opinionCount = fetchedCases.reduce<number>( + (sum, fetchedCase) => sum + courtlistenerOpinionCount(fetchedCase), + 0, + ); + const caseOpinionCountByClusterId = new Map<number, number>(); + fetchedCases.forEach((fetchedCase, index) => { + const clusterId = + courtlistenerCaseInputFromFetchedCase( + clusterIds[index] ?? 0, + fetchedCase, + ).clusterId ?? 0; + if (clusterId) { + caseOpinionCountByClusterId.set( + clusterId, + courtlistenerOpinionCount(fetchedCase), + ); + } + }); + const errors = fetchedCases + .map((fetchedCase) => + stringField(recordFromUnknown(fetchedCase), "error"), + ) + .filter((error): error is string => !!error); + const resultError = + result && + typeof result === "object" && + typeof (result as { error?: unknown }).error === "string" + ? (result as { error: string }).error + : undefined; + const hasMultipleOpinionCase = caseRecords.some( + (record) => + (caseOpinionCountByClusterId.get(record.clusterId) ?? 0) > 1, + ); + const event: CourtlistenerToolEvent = { + type: "courtlistener_get_cases", + cluster_ids: clusterIds, + case_count: fetchedCases.length, + opinion_count: opinionCount, + cases: caseRecords.map((record) => ({ + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + dateFiled: record.dateFiled, + url: record.url, + })), + ...(resultError || errors.length + ? { error: resultError ?? errors.join("; ") } + : {}), + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: !resultError && errors.length === 0, + cluster_ids: clusterIds, + case_count: fetchedCases.length, + opinion_count: opinionCount, + cases: caseRecords.map((record) => + courtlistenerFetchedCaseMetadata( + record, + caseOpinionCountByClusterId.get(record.clusterId) ?? 0, + ), + ), + ...(resultError || errors.length + ? { error: resultError ?? errors.join("; ") } + : {}), + next_required_action: hasMultipleOpinionCase + ? "Opinion text is cached server-side only. Use courtlistener_find_in_case with short 1-3 word keyword probes for relevant passages. At least one fetched case has multiple opinions; if snippets are insufficient, choose the needed opinion_id(s) from the text-free opinion metadata and call courtlistener_read_case with only those IDs. Do not read all opinions unless the question requires it." + : "Opinion text is cached server-side only. Use courtlistener_find_in_case with short 1-3 word keyword probes for relevant passages, or courtlistener_read_case if snippets are insufficient.", + }), + }); + } catch (err) { + const event: CourtlistenerToolEvent = { + type: "courtlistener_get_cases", + cluster_ids: clusterIds, + case_count: 0, + opinion_count: 0, + error: + err instanceof Error + ? err.message + : "CourtListener case fetch failed.", + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + error: + err instanceof Error + ? err.message + : "CourtListener case fetch failed.", + }), + }); + } + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase) { + const { clusterId, query, maxResults, contextChars } = + parseFindInCaseArgs(args); + if (shouldGroupFindInCase) { + if (!groupedFindInCaseStarted) { + write( + `data: ${JSON.stringify({ + type: "courtlistener_find_in_case_start", + cluster_id: null, + query: "", + searches: groupedFindInCaseSearches, + })}\n\n`, + ); + groupedFindInCaseStarted = true; + } + } else { + write( + `data: ${JSON.stringify({ type: "courtlistener_find_in_case_start", cluster_id: clusterId, query })}\n\n`, + ); + } + + const record = + typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined; + if (!record) { + const payload = cachedCaseNotFetchedResult(clusterId); + const event: CourtlistenerToolEvent = { + type: "courtlistener_find_in_case", + cluster_id: clusterId, + query, + total_matches: 0, + error: payload.error, + }; + if (shouldGroupFindInCase) { + groupedFindInCaseEvents.push(event); + } else { + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + } + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(payload), + }); + continue; + } + + const opinions = cachedCaseOpinionTexts(record); + const hits: Array< + TextMatch & { + opinion_id: number | null; + type: string | null; + author: string | null; + url: string | null; + } + > = []; + let totalMatches = 0; + for (const opinion of opinions) { + const remaining = Math.max(0, maxResults - hits.length); + const result = findTextMatches({ + text: opinion.text, + query, + maxResults: remaining, + contextChars, + startIndex: hits.length, + }); + totalMatches += result.totalMatches; + hits.push( + ...result.hits.map((hit) => ({ + ...hit, + opinion_id: opinion.opinion_id, + type: opinion.type, + author: opinion.author, + url: opinion.url, + })), + ); + } + + const event: CourtlistenerToolEvent = { + type: "courtlistener_find_in_case", + cluster_id: record.clusterId, + query, + total_matches: totalMatches, + case_name: record.caseName, + citation: record.citations[0] ?? null, + }; + if (shouldGroupFindInCase) { + groupedFindInCaseEvents.push(event); + } else { + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + } + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: true, + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + query, + total_matches: totalMatches, + returned: hits.length, + truncated: totalMatches > hits.length, + hits, + }), + }); + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.readCase) { + const clusterId = + typeof args.clusterId === "number" && Number.isFinite(args.clusterId) + ? Math.floor(args.clusterId) + : typeof args.cluster_id === "number" && + Number.isFinite(args.cluster_id) + ? Math.floor(args.cluster_id) + : null; + write( + `data: ${JSON.stringify({ type: "courtlistener_read_case_start", cluster_id: clusterId })}\n\n`, + ); + + const record = + typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined; + if (!record) { + const payload = cachedCaseNotFetchedResult(clusterId); + const event: CourtlistenerToolEvent = { + type: "courtlistener_read_case", + cluster_id: clusterId, + opinion_count: 0, + error: payload.error, + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(payload), + }); + continue; + } + + const opinions = cachedCaseOpinionTexts(record); + const requestedOpinionIds = requestedCourtlistenerOpinionIds(args); + const selectedOpinions = + requestedOpinionIds.length > 0 + ? opinions.filter( + (opinion) => + typeof opinion.opinion_id === "number" && + requestedOpinionIds.includes(opinion.opinion_id), + ) + : opinions.length === 1 + ? opinions + : []; + if (!selectedOpinions.length) { + const multipleOpinions = opinions.length > 1; + const payload = { + ok: false, + cluster_id: record.clusterId, + case_name: record.caseName, + citations: record.citations, + url: record.url, + dateFiled: record.dateFiled, + opinion_count: opinions.length, + opinions: (record.opinions ?? []) + .map(courtlistenerOpinionMetadata) + .filter( + (opinion): opinion is NonNullable<typeof opinion> => + !!opinion, + ), + error: multipleOpinions + ? "Multiple opinions are available. Call courtlistener_read_case again with the opinionId or opinionIds needed." + : "No matching opinion_id was found for this fetched case.", + }; + const event: CourtlistenerToolEvent = { + type: "courtlistener_read_case", + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + opinion_count: 0, + error: payload.error, + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(payload), + }); + continue; + } + + const event: CourtlistenerToolEvent = { + type: "courtlistener_read_case", + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + opinion_count: selectedOpinions.length, + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: true, + cluster_id: record.clusterId, + case_name: record.caseName, + citations: record.citations, + url: record.url, + dateFiled: record.dateFiled, + opinion_count: opinions.length, + returned_opinion_count: selectedOpinions.length, + opinions: selectedOpinions, + }), + }); + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.verifyCitations) { + const citations = Array.isArray(args.citations) + ? args.citations.filter( + (value): value is string => typeof value === "string", + ) + : []; + const citationCount = citations.length; + write( + `data: ${JSON.stringify({ type: "courtlistener_verify_citations_start", citation_count: citationCount })}\n\n`, + ); + try { + const result = (await verifyCourtlistenerCitations({ + citations, + db, + apiToken: apiKeys?.courtlistener, + })) as { + citationLinks?: { + clusterId?: number | null; + citation?: string | null; + caseName?: string | null; + dateFiled?: string | null; + pdfUrl?: string | null; + url?: string | null; + markdown?: string; + }[]; + results?: unknown[]; + error?: string; + source?: string; + [key: string]: unknown; + }; + if (Array.isArray(result.citationLinks)) { + const caseRecords = upsertCourtlistenerCases( + courtState, + result.citationLinks.map((link) => ({ + clusterId: link.clusterId, + caseName: link.caseName, + citation: link.citation, + url: link.url, + pdfUrl: link.pdfUrl, + dateFiled: link.dateFiled, + })), + ); + const recordsByClusterId = new Map( + caseRecords.map((record) => [record.clusterId, record]), + ); + result.citationLinks = result.citationLinks.map((link) => { + if (!link.url) return link; + const href = + typeof link.clusterId === "number" + ? `us-case-${link.clusterId}` + : link.url; + const label = [link.caseName, link.citation] + .filter(Boolean) + .join(", "); + const record = + typeof link.clusterId === "number" + ? recordsByClusterId.get(link.clusterId) + : undefined; + if (record) { + const event = caseCitationEventFromRecord(record); + if (event) { + caseCitationEvents.push(event); + write(`data: ${JSON.stringify(event)}\n\n`); + } + } + return { + ...link, + markdown: `[${label || link.url}](${href})`, + }; + }); + } + const rows = + result && + typeof result === "object" && + Array.isArray((result as { results?: unknown }).results) + ? (result as { results: unknown[] }).results + : []; + const matchCount = rows.reduce<number>((count, row) => { + if (!row || typeof row !== "object") return count; + const clusters = (row as { clusters?: unknown }).clusters; + return count + (Array.isArray(clusters) ? clusters.length : 0); + }, 0); + const error = + result && + typeof result === "object" && + typeof (result as { error?: unknown }).error === "string" + ? (result as { error: string }).error + : undefined; + const event: CourtlistenerToolEvent = { + type: "courtlistener_verify_citations", + citation_count: citationCount, + match_count: matchCount, + ...(error ? { error } : {}), + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(result), + }); + } catch (err) { + const event: CourtlistenerToolEvent = { + type: "courtlistener_verify_citations", + citation_count: citationCount, + match_count: 0, + error: + err instanceof Error + ? err.message + : "CourtListener citation lookup failed.", + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + error: + err instanceof Error + ? err.message + : "CourtListener citation lookup failed.", + }), + }); + } + } else if (tc.function.name === "edit_document" && docIndex) { + const rawDocId = args.doc_id as string; + const editsRaw = args.edits as unknown[] | undefined; + const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; + const docInfo = docStore.get(docId); + const indexed = docIndex?.[docId]; + + const emitEditError = ( + filename: string, + documentId: string, + error: string, + ) => { + // Surface the failure as a failed "Edited" block in the UI + // (start → done-with-error) so it matches the shape the + // success/late-failure paths already use. + write( + `data: ${JSON.stringify({ + type: "doc_edited_start", + filename, + })}\n\n`, + ); + write( + `data: ${JSON.stringify({ + type: "doc_edited", + filename, + document_id: documentId, + version_id: "", + download_url: "", + annotations: [], + error, + })}\n\n`, + ); + }; + + if (!docInfo || !indexed) { + const err = `Document '${docId}' not found in this chat's attachments.`; + emitEditError(docId, indexed?.document_id ?? "", err); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ error: err }), + }); + } else if (!Array.isArray(editsRaw) || editsRaw.length === 0) { + const err = "edits array is required and must not be empty."; + emitEditError(docInfo.filename, indexed.document_id, err); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ error: err }), + }); + } else if (docInfo.file_type !== "docx") { + const err = "edit_document only supports .docx files."; + emitEditError(docInfo.filename, indexed.document_id, err); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ error: err }), + }); + } else { + write( + `data: ${JSON.stringify({ + type: "doc_edited_start", + filename: docInfo.filename, + })}\n\n`, + ); + const edits: EditInput[] = (editsRaw as Record<string, unknown>[]).map( + (e) => ({ + find: String(e.find ?? ""), + replace: String(e.replace ?? ""), + context_before: String(e.context_before ?? ""), + context_after: String(e.context_after ?? ""), + reason: e.reason ? String(e.reason) : undefined, + }), + ); + const reuseVersion = turnEditState?.get(indexed.document_id); + const result = await runEditDocument({ + documentId: indexed.document_id, + userId, + edits, + db, + reuseVersion, + }); + + if (result.ok) { + turnEditState?.set(indexed.document_id, { + versionId: result.version_id, + versionNumber: result.version_number, + storagePath: result.storage_path, + }); + clearTurnReadsForDocument(turnReadState, indexed.document_id); + // Keep the chat-local doc label pointed at the latest + // edited version so any follow-up read_document call in + // the same assistant turn reads and cites the same bytes. + if (docIndex[docId]) { + docIndex[docId] = { + ...docIndex[docId], + version_id: result.version_id, + version_number: result.version_number, + }; + } + const currentDocStore = docStore.get(docId); + if (currentDocStore) { + docStore.set(docId, { + ...currentDocStore, + storage_path: result.storage_path, + }); + } + const payload: DocEditedResult = { + filename: docInfo.filename, + document_id: indexed.document_id, + version_id: result.version_id, + version_number: result.version_number, + download_url: result.download_url, + annotations: result.annotations, + }; + docsEdited.push(payload); + write( + `data: ${JSON.stringify({ + type: "doc_edited", + ...payload, + })}\n\n`, + ); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: true, + doc_id: docId, + document_id: indexed.document_id, + version_id: result.version_id, + version_number: result.version_number, + applied: result.annotations.length, + errors: result.errors, + next_required_action: [ + `The edited document remains available as doc_id "${docId}".`, + `Before making factual claims about the edited document's final contents, call read_document with doc_id "${docId}" and base the response on that returned text.`, + `Do not include download links or URLs in your prose response; the edited document card is shown automatically by the UI.`, + `If you describe specific content from the edited document, cite it with [N] markers and a final <CITATIONS> block using doc_id "${docId}".`, + ].join(" "), + }), + }); + } else { + write( + `data: ${JSON.stringify({ + type: "doc_edited", + filename: docInfo.filename, + document_id: indexed.document_id, + version_id: "", + download_url: "", + annotations: [], + error: result.error, + })}\n\n`, + ); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: false, + error: result.error, + }), + }); + } + } + } else if (tc.function.name === "replicate_document" && docIndex) { + const rawDocId = args.doc_id as string; + const requestedFilename = + typeof args.new_filename === "string" && args.new_filename.trim() + ? args.new_filename.trim() + : null; + const requestedCount = + typeof args.count === "number" && Number.isFinite(args.count) + ? Math.max(1, Math.min(20, Math.floor(args.count))) + : 1; + const sourceLabel = + resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; + const sourceInfo = docStore.get(sourceLabel); + const sourceIndexed = docIndex[sourceLabel]; + const sourceFilename = sourceInfo?.filename ?? rawDocId; + + write( + `data: ${JSON.stringify({ + type: "doc_replicate_start", + filename: sourceFilename, + count: requestedCount, + })}\n\n`, + ); + + const fail = (error: string) => { + write( + `data: ${JSON.stringify({ + type: "doc_replicated", + filename: sourceFilename, + count: requestedCount, + copies: [], + error, + })}\n\n`, + ); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ ok: false, error }), + }); + }; + + if (!sourceInfo || !sourceIndexed) { + fail(`Document '${rawDocId}' not found in this project.`); + } else if (!projectId) { + fail("replicate_document is only available in project chats."); + } else { + try { + // Pull the active version once — every copy gets the + // same starting bytes (with any accepted tracked + // changes rolled in), no point re-fetching per copy. + const active = await loadActiveVersion(sourceIndexed.document_id, db); + const sourcePath = active?.storage_path ?? sourceInfo.storage_path; + const sourcePdfPath = active?.pdf_storage_path ?? null; + const raw = await downloadFile(sourcePath); + const pdfBytes = sourcePdfPath + ? await downloadFile(sourcePdfPath) + : null; + if (!raw) { + fail("Could not read the source document's bytes from storage."); + } else { + // Build N filenames. With count=1 keep the + // pre-existing "(copy)" suffix; with count>1 use + // numbered "(1)", "(2)" suffixes. + const srcExt = sourceInfo.filename.match(/\.[^./\\]+$/)?.[0] ?? ""; + const baseStem = (() => { + if (requestedFilename) { + return requestedFilename.replace(/\.[^./\\]+$/, ""); + } + return sourceInfo.filename.replace(/\.[^./\\]+$/, ""); + })(); + const filenames: string[] = []; + for (let n = 1; n <= requestedCount; n++) { + const suffix = + requestedCount === 1 + ? requestedFilename + ? "" + : " (copy)" + : ` (${n})`; + filenames.push(`${baseStem}${suffix}${srcExt}`); + } + + // Bulk insert N documents in one round-trip. + const docRows = filenames.map((fn) => ({ + project_id: projectId, + user_id: userId, + status: "ready", + })); + const { data: insertedDocs, error: docErr } = await db + .from("documents") + .insert(docRows) + .select("id"); + if (docErr || !insertedDocs || insertedDocs.length === 0) { + fail( + `Failed to record replicated documents: ${docErr?.message ?? "unknown"}`, + ); + } else { + // Preserve the request order so each row pairs + // with the right filename. Supabase returns + // inserted rows in the same order as the + // payload. + const newDocs = (insertedDocs as { id: string }[]).map( + (doc, idx) => ({ + ...doc, + filename: filenames[idx] ?? "Untitled document.docx", + }), + ); + const contentType = contentTypeForDocumentType( + sourceInfo.file_type, + ); + + // Parallel uploads: the doc bytes (and PDF + // rendition if any) for every new copy. + const uploadJobs: Promise<unknown>[] = []; + const newKeys: string[] = []; + const newPdfKeys: (string | null)[] = []; + for (const d of newDocs) { + const key = storageKey(userId, d.id, d.filename); + newKeys.push(key); + uploadJobs.push(uploadFile(key, raw, contentType)); + if (pdfBytes) { + const pdfKey = convertedPdfKey(userId, d.id); + newPdfKeys.push(pdfKey); + uploadJobs.push( + uploadFile(pdfKey, pdfBytes, "application/pdf"), + ); + } else { + newPdfKeys.push(null); + } + } + await Promise.all(uploadJobs); + + // Bulk insert N versions in one round-trip. + const versionRows = newDocs.map((d, idx) => ({ + document_id: d.id, + storage_path: newKeys[idx], + pdf_storage_path: newPdfKeys[idx], + source: "upload", + version_number: 1, + filename: d.filename, + file_type: active?.file_type ?? sourceInfo.file_type, + size_bytes: active?.size_bytes ?? raw.byteLength, + page_count: active?.page_count ?? null, + })); + const { data: insertedVersions, error: verErr } = await db + .from("document_versions") + .insert(versionRows) + .select("id, document_id"); + if ( + verErr || + !insertedVersions || + insertedVersions.length !== newDocs.length + ) { + fail( + `Failed to record replicated document versions: ${verErr?.message ?? "unknown"}`, + ); + } else { + const versionByDocId = new Map<string, string>(); + for (const v of insertedVersions as { + id: string; + document_id: string; + }[]) { + versionByDocId.set(v.document_id, v.id); + } + + // current_version_id has to be a per-row + // value, so a single UPDATE statement + // can't cover all N. Fan out in parallel + // instead of sequential awaits. + await Promise.all( + newDocs.map((d) => + db + .from("documents") + .update({ + current_version_id: versionByDocId.get(d.id), + }) + .eq("id", d.id), + ), + ); + + // Register every copy under a fresh doc-N + // slug so the model can edit/read any of + // them in the same turn. + const existingLabels = new Set(Object.keys(docIndex)); + let nextLabelIdx = 0; + const copies: { + new_filename: string; + document_id: string; + version_id: string; + }[] = []; + const toolPayloadCopies: { + doc_id: string; + document_id: string; + version_id: string; + filename: string; + download_url: string; + }[] = []; + for (let idx = 0; idx < newDocs.length; idx++) { + const d = newDocs[idx]; + const newKey = newKeys[idx]; + const versionId = versionByDocId.get(d.id); + if (!versionId) continue; + while (existingLabels.has(`doc-${nextLabelIdx}`)) + nextLabelIdx++; + const slug = `doc-${nextLabelIdx}`; + existingLabels.add(slug); + docIndex[slug] = { + document_id: d.id, + filename: d.filename, + }; + docStore.set(slug, { + storage_path: newKey, + file_type: sourceInfo.file_type, + filename: d.filename, + }); + copies.push({ + new_filename: d.filename, + document_id: d.id, + version_id: versionId, + }); + toolPayloadCopies.push({ + doc_id: slug, + document_id: d.id, + version_id: versionId, + filename: d.filename, + download_url: buildDownloadUrl(newKey, d.filename), + }); + } + + write( + `data: ${JSON.stringify({ + type: "doc_replicated", + filename: sourceFilename, + count: copies.length, + copies, + })}\n\n`, + ); + docsReplicated.push({ + filename: sourceFilename, + count: copies.length, + copies, + }); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: true, + count: copies.length, + copies: toolPayloadCopies, + }), + }); + } + } + } + } catch (e) { + fail(`replicate_document failed: ${String(e)}`); + } + } + } else if (tc.function.name === "generate_docx") { + const title = args.title as string; + const landscape = !!args.landscape; + devLog( + `[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`, + ); + const previewFilename = safeGeneratedFilename(title, "docx"); + write( + `data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`, + ); + const result = await generateDocx( + title, + args.sections as unknown[], + userId, + db, + { landscape, projectId: projectId ?? null }, + ); + registerGeneratedDocument( + tc, + result as Record<string, unknown>, + previewFilename, + "docx", + ); + } else if (tc.function.name === "generate_excel") { + const title = args.title as string; + devLog(`[generate_excel] title="${title}"`); + const previewFilename = safeGeneratedFilename(title, "xlsx"); + write( + `data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`, + ); + const result = await generateExcel( + title, + args.sheets as unknown[], + userId, + db, + { projectId: projectId ?? null }, + ); + registerGeneratedDocument( + tc, + result as Record<string, unknown>, + previewFilename, + "xlsx", + ); + } else if (tc.function.name === "generate_ppt") { + const title = args.title as string; + devLog(`[generate_ppt] title="${title}"`); + const previewFilename = safeGeneratedFilename(title, "pptx"); + write( + `data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`, + ); + const result = await generatePpt( + title, + args.slides as unknown[], + userId, + db, + { projectId: projectId ?? null }, + ); + registerGeneratedDocument( + tc, + result as Record<string, unknown>, + previewFilename, + "pptx", + ); + } + } + + if (shouldGroupFindInCase && groupedFindInCaseEvents.length > 0) { + const errors = groupedFindInCaseEvents + .map((event) => event.error) + .filter((error): error is string => !!error); + const groupEvent: CourtlistenerToolEvent = { + type: "courtlistener_find_in_case", + cluster_id: null, + query: "", + total_matches: groupedFindInCaseEvents.reduce( + (sum, event) => sum + event.total_matches, + 0, + ), + searches: groupedFindInCaseEvents.map(findInCaseSearchSummary), + ...(errors.length ? { error: errors.join("; ") } : {}), + }; + write(`data: ${JSON.stringify(groupEvent)}\n\n`); + courtlistenerEvents.push(groupEvent); + } + + return { + toolResults, + docsRead, + docsFound, + docsCreated, + docsReplicated, + workflowsApplied, + docsEdited, + askInputsEvents, + courtlistenerEvents, + caseCitationEvents, + mcpEvents, + }; +} diff --git a/backend/src/lib/chat/tools/toolSchemas.ts b/backend/src/lib/chat/tools/toolSchemas.ts new file mode 100644 index 0000000..302ceb9 --- /dev/null +++ b/backend/src/lib/chat/tools/toolSchemas.ts @@ -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"], + }, + }, + }, +]; diff --git a/backend/src/lib/chat/types.ts b/backend/src/lib/chat/types.ts new file mode 100644 index 0000000..60c10fb --- /dev/null +++ b/backend/src/lib/chat/types.ts @@ -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; skill_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"; +}; diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts deleted file mode 100644 index a0b705e..0000000 --- a/backend/src/lib/chatTools.ts +++ /dev/null @@ -1,4590 +0,0 @@ -import path from "path"; -import { - downloadFile, - generatedDocKey, - storageKey, - uploadFile, -} from "./storage"; -import { convertedPdfKey } from "./convert"; -import { createServerSupabase } from "./supabase"; -import { - applyTrackedEdits, - extractDocxBodyText, - type EditInput, -} from "./docxTrackedChanges"; -import { buildDownloadUrl } from "./downloadTokens"; -import { - attachActiveVersionPaths, - loadActiveVersion, -} from "./documentVersions"; -import { - getCourtlistenerCaseOpinions, - getCourtlistenerCases, - searchCourtlistenerCaseLaw, - verifyCourtlistenerCitations, -} from "./courtlistener"; -import { - COURTLISTENER_SYSTEM_PROMPT, - COURTLISTENER_TOOL_NAMES, - COURTLISTENER_TOOLS, - type CaseCitationEvent, - type CourtlistenerToolEvent, -} from "./legalSourcesTools/courtlistenerTools"; -import { - buildUserMcpTools, - executeMcpToolCall, - type McpToolEvent, -} from "./mcpConnectors"; -import { - streamChatWithTools, - resolveModel, - DEFAULT_MAIN_MODEL, - type LlmMessage, - type OpenAIToolSchema, -} from "./llm"; -import { safeErrorMessage } from "./safeError"; - -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"; -const devLog = (...args: Parameters<typeof console.log>) => { - if (isDev) console.log(...args); -}; - -// --------------------------------------------------------------------------- -// 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 }; -}; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -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. -- 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. - -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. The marker number is the citation "ref" value, not a page, footnote, section, 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. -- "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. -- 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 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: -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. Gated per-user by the Legal Research > US feature toggle. - */ -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); - -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.", - 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: "read_document", - description: - "Read the full text content of a document attached by the user. Always call this before answering questions about, summarising, or citing from a document.", - 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: "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. 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"], - }, - }, - }, -]; - -type ParsedDocumentCitation = { - kind: "document"; - ref: number; - doc_id: string; - page: number | string; - quote: string; - quotes: { - page: number | string; - quote: string; - }[]; -}; - -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 }); - } - return { - kind: "document", - ref, - doc_id: c.doc_id, - page: quotes[0].page, - quote: quotes[0].quote, - quotes, - }; -} - -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>) { - 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; - return { - page: normalizeCitationPage(row.page ?? c.page), - quote: text, - }; - }) - .filter( - (quote): quote is { page: number | string; quote: string } => !!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, - ); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -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. Generated docs surface in - * tool results with both `doc_id` (slug) and `document_id` (UUID), so the - * model often picks the wrong one — without this fallback `read_document` - * silently returns "not found" and the model gives up and re-generates. - */ -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; -} - -function citationReminder(docLabel: string, filename: string): string { - return [ - `[Citation requirement for ${docLabel} ("${filename}")]:`, - `If your final answer makes any factual claim from this document, include inline [N] markers and append a final <CITATIONS> JSON block.`, - `Every citation entry for this document MUST use "doc_id": "${docLabel}".`, - `Use this citation object shape: {"ref": 1, "doc_id": "${docLabel}", "quotes": [{"page": 1, "quote": "exact verbatim text from the document"}]}. Include top-level "page" and "quote" too only if they match the first quote.`, - `Do not use "marker" or "text" keys in the citation block; use "ref" and "quotes".`, - ].join("\n"); -} - -/** - * Append a tool-activity summary to the most recent assistant message so - * the model can see what it just did (read / create / edit / workflow - * applied) in the prior turn — otherwise it only sees its own prose and - * forgets which docs it touched, which leads to e.g. re-generating a doc - * that already exists. - * - * Doc references use the *current-turn* `doc_id` slug (looked up by - * matching the event's stored `document_id` against this turn's freshly - * built `docIndex`), since slugs are reassigned every turn and the old - * slug from the prior turn would be meaningless. Falls back to filename - * only if the doc is no longer in the index (deleted, scope changed). - */ -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(`- generate_docx → ${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}"`); - } - } - 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) at the start of every response that involves a document's content, even if you have read it in a previous turn. Failure to do so 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 async function extractPdfText(buf: ArrayBuffer): Promise<string> { - try { - const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs" as string); - const pdf = await ( - pdfjsLib as unknown as { - getDocument: (opts: unknown) => { - promise: Promise<{ - numPages: number; - getPage: (n: number) => Promise<{ - getTextContent: () => Promise<{ - items: { str?: string }[]; - }>; - }>; - }>; - }; - } - ).getDocument({ - data: new Uint8Array(buf), - standardFontDataUrl: STANDARD_FONT_DATA_URL, - }).promise; - const parts: string[] = []; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const textContent = await page.getTextContent(); - parts.push( - `[Page ${i}]\n${textContent.items.map((it) => it.str ?? "").join(" ")}`, - ); - } - return parts.join("\n\n"); - } catch { - return ""; - } -} - -export async function generateDocx( - title: string, - sections: unknown[], - userId: string, - db: ReturnType<typeof createServerSupabase>, - options?: { landscape?: boolean; projectId?: string | null }, -) { - try { - const { - Document, - Paragraph, - HeadingLevel, - Packer, - Table, - TableRow, - TableCell, - WidthType, - BorderStyle, - TextRun, - AlignmentType, - LevelFormat, - LevelSuffix, - PageOrientation, - PageBreak, - } = await import("docx"); - - const FONT = "Times New Roman"; - const SIZE = 22; // 11pt in half-points - - type DocChild = InstanceType<typeof Paragraph> | InstanceType<typeof Table>; - const children: DocChild[] = []; - children.push( - new Paragraph({ - heading: HeadingLevel.TITLE, - spacing: { after: 200 }, - alignment: AlignmentType.CENTER, - children: [ - new TextRun({ - text: title.toUpperCase(), - color: "000000", - font: FONT, - size: SIZE, - bold: true, - }), - ], - }), - ); - - const cellBorder = { - top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - }; - - const headingLevels = [ - HeadingLevel.HEADING_1, - HeadingLevel.HEADING_2, - HeadingLevel.HEADING_3, - HeadingLevel.HEADING_4, - ]; - const LEGAL_NUMBERING_REF = "legal-clause-numbering"; - const legalNumbering = (level: number) => ({ - reference: LEGAL_NUMBERING_REF, - level: Math.max(0, Math.min(level, 4)), - }); - const legalNumberingLevels = [ - { - level: 0, - format: LevelFormat.DECIMAL, - text: "%1.", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - isLegalNumberingStyle: true, - style: { - paragraph: { indent: { left: 720, hanging: 720 } }, - run: { - bold: true, - color: "000000", - font: FONT, - size: SIZE, - }, - }, - }, - { - level: 1, - format: LevelFormat.DECIMAL, - text: "%1.%2", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - isLegalNumberingStyle: true, - style: { - paragraph: { indent: { left: 720, hanging: 720 } }, - run: { color: "000000", font: FONT, size: SIZE }, - }, - }, - { - level: 2, - format: LevelFormat.LOWER_LETTER, - text: "(%3)", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - style: { - paragraph: { indent: { left: 1440, hanging: 720 } }, - run: { color: "000000", font: FONT, size: SIZE }, - }, - }, - { - level: 3, - format: LevelFormat.LOWER_ROMAN, - text: "(%4)", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - style: { - paragraph: { indent: { left: 1440, hanging: 720 } }, - run: { color: "000000", font: FONT, size: SIZE }, - }, - }, - { - level: 4, - format: LevelFormat.UPPER_LETTER, - text: "(%5)", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - style: { - paragraph: { indent: { left: 2520, hanging: 720 } }, - run: { color: "000000", font: FONT, size: SIZE }, - }, - }, - ]; - const normalizeTable = ( - table: unknown, - ): { headers: string[]; rows: string[][] } | null => { - if (!table || typeof table !== "object") return null; - const raw = table as { headers?: unknown; rows?: unknown }; - const headers = Array.isArray(raw.headers) - ? raw.headers - .map((header) => (typeof header === "string" ? header.trim() : "")) - .filter(Boolean) - : []; - if (headers.length === 0) return null; - - const rawRows = Array.isArray(raw.rows) ? raw.rows : []; - const rows = rawRows - .filter((row): row is unknown[] => Array.isArray(row)) - .map((row) => - headers.map((_, i) => (typeof row[i] === "string" ? row[i] : "")), - ); - - return { headers, rows }; - }; - const stripManualNumbering = ( - value: string, - ): { text: string; levelFromPrefix: number | null } => { - const match = value.trim().match(/^(\d+(?:\.\d+)*)(?:[.)])?\s+(.+)$/); - if (!match) return { text: value.trim(), levelFromPrefix: null }; - return { - text: match[2].trim(), - levelFromPrefix: match[1].split(".").length - 1, - }; - }; - const parseManualListMarker = ( - value: string, - ): { text: string; levelOffset: number | null } => { - const trimmed = value.trim(); - const match = trimmed.match(/^(\(([a-z]+)\)|([a-z]+)[.)])\s+(.+)$/i); - if (!match) return { text: trimmed, levelOffset: null }; - const marker = (match[2] ?? match[3] ?? "").toLowerCase(); - const isRoman = - marker === "i" || - (marker.length > 1 && - /^(?:m{0,4}(?:cm|cd|d?c{0,3})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$/i.test( - marker, - )); - return { text: match[4].trim(), levelOffset: isRoman ? 3 : 2 }; - }; - const normalizeHeadingText = (value: string) => - value - .trim() - .replace(/[^a-zA-Z0-9]+/g, " ") - .trim() - .toLowerCase(); - - const isTitleLikeFirstHeading = (heading: string, sectionIndex: number) => { - if (sectionIndex !== 0) return false; - const normalized = normalizeHeadingText(heading); - const titleNormalized = normalizeHeadingText(title); - if (!normalized || !titleNormalized) return false; - if (normalized === titleNormalized) return true; - return ( - titleNormalized.includes(normalized) && - /\b(agreement|contract|deed|terms|policy|notice|nda|disclosure)\b/.test( - normalized, - ) - ); - }; - - const isUnnumberedHeading = (heading: string, sectionIndex: number) => { - const normalized = normalizeHeadingText(heading); - if (!normalized) return true; - if (normalized === "signatures" || normalized === "signature") { - return true; - } - if (isTitleLikeFirstHeading(heading, sectionIndex)) { - return true; - } - if ( - sectionIndex === 0 && - /^(agreement|contract|mutual non disclosure agreement|non disclosure agreement|employment agreement|service level agreement)$/.test( - normalized, - ) - ) { - return true; - } - return false; - }; - const isSignatureLine = (value: string) => - /^(?:by|name|title|date):\s*/i.test(value.trim()); - const looksLikeSignatureBlock = (value: string) => { - const lines = value - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - if (lines.length === 0) return false; - const signatureLineCount = lines.filter(isSignatureLine).length; - return signatureLineCount >= 2; - }; - let currentClauseLevel: number | null = null; - - for (const [sectionIndex, section] of ( - sections as { - heading?: string; - content?: string; - level?: number; - pageBreak?: boolean; - table?: { headers: string[]; rows: string[][] }; - }[] - ).entries()) { - if (section.pageBreak) { - children.push(new Paragraph({ children: [new PageBreak()] })); - } - if (section.heading) { - const stripped = stripManualNumbering(section.heading); - const isUnnumbered = isUnnumberedHeading(stripped.text, sectionIndex); - const skipHeading = isTitleLikeFirstHeading( - stripped.text, - sectionIndex, - ); - const idx = Math.min( - stripped.levelFromPrefix ?? (section.level ?? 1) - 1, - 3, - ); - currentClauseLevel = isUnnumbered || skipHeading ? null : idx; - const headingText = - idx === 0 && !isUnnumbered - ? stripped.text.toUpperCase() - : stripped.text; - if (!skipHeading) { - children.push( - new Paragraph({ - heading: headingLevels[idx], - numbering: isUnnumbered ? undefined : legalNumbering(idx), - spacing: { after: 160 }, - children: [ - new TextRun({ - text: headingText, - color: "000000", - font: FONT, - size: SIZE, - bold: true, - }), - ], - }), - ); - } - } - const normalizedTable = normalizeTable(section.table); - if (normalizedTable) { - const { headers, rows } = normalizedTable; - const colCount = headers.length; - const tableRows: InstanceType<typeof TableRow>[] = []; - // Header row - tableRows.push( - new TableRow({ - tableHeader: true, - children: headers.map( - (h) => - new TableCell({ - borders: cellBorder, - shading: { fill: "F2F2F2" }, - children: [ - new Paragraph({ - children: [ - new TextRun({ - text: h, - bold: true, - font: FONT, - size: SIZE, - }), - ], - alignment: AlignmentType.LEFT, - }), - ], - }), - ), - }), - ); - // Data rows — normalize each row to exactly colCount cells. - // LLMs occasionally emit malformed rows (extra fragments from - // stray delimiters, or short rows); padding/truncating here - // keeps the rendered table aligned to the headers. - for (const normalized of rows) { - tableRows.push( - new TableRow({ - children: normalized.map( - (cell) => - new TableCell({ - borders: cellBorder, - children: [ - new Paragraph({ - children: [ - new TextRun({ - text: cell, - font: FONT, - size: SIZE, - }), - ], - }), - ], - }), - ), - }), - ); - } - children.push( - new Table({ - width: { size: 100, type: WidthType.PERCENTAGE }, - rows: tableRows, - }), - ); - children.push(new Paragraph({ text: "" })); - } - if (section.content) { - let numberedBodyParagraphs = 0; - const contentIsSignatureBlock = - section.heading && - normalizeHeadingText(section.heading).includes("signature") - ? true - : looksLikeSignatureBlock(section.content); - for (const line of section.content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/); - const rawText = bulletMatch ? bulletMatch[1].trim() : trimmed; - const manualList = parseManualListMarker(rawText); - const numeric = stripManualNumbering(rawText); - const text = bulletMatch - ? rawText - : manualList.levelOffset !== null - ? manualList.text - : numeric.text; - const inferredLevel = - currentClauseLevel === null || contentIsSignatureBlock - ? undefined - : bulletMatch - ? currentClauseLevel + 2 - : manualList.levelOffset !== null - ? currentClauseLevel + manualList.levelOffset - : numeric.levelFromPrefix !== null - ? numeric.levelFromPrefix - : numberedBodyParagraphs === 0 - ? currentClauseLevel + 1 - : currentClauseLevel + 2; - if (currentClauseLevel !== null) numberedBodyParagraphs++; - children.push( - new Paragraph({ - numbering: - inferredLevel === undefined - ? undefined - : legalNumbering(inferredLevel), - spacing: { after: 120 }, - children: [ - new TextRun({ - text, - font: FONT, - size: SIZE, - }), - ], - }), - ); - } - } - } - - const pageSetup = options?.landscape - ? { page: { size: { orientation: PageOrientation.LANDSCAPE } } } - : {}; - - const doc = new Document({ - numbering: { - config: [ - { - reference: LEGAL_NUMBERING_REF, - levels: legalNumberingLevels, - }, - ], - }, - sections: [{ properties: pageSetup, children }], - }); - const buf = await Packer.toBuffer(doc); - const zip = await import("jszip"); - const packageZip = await zip.default.loadAsync(buf); - for (const requiredPath of [ - "[Content_Types].xml", - "word/document.xml", - "word/_rels/document.xml.rels", - ]) { - if (!packageZip.file(requiredPath)) { - return { - error: `Generated DOCX is missing required package part: ${requiredPath}`, - }; - } - } - const docId = crypto.randomUUID().replace(/-/g, ""); - const safeTitle = - title - .replace(/[^a-zA-Z0-9 -]/g, "") - .trim() - .slice(0, 64) || "document"; - const filename = `${safeTitle}.docx`; - const key = generatedDocKey(userId, docId, filename); - - await uploadFile( - key, - buf.buffer as ArrayBuffer, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ); - const downloadUrl = buildDownloadUrl(key, filename); - - // Persist to DB so generated docs are first-class documents: - // openable in the DocPanel and editable via edit_document. In - // project chats we attach to the project so it appears in the - // sidebar; in the general chat we leave project_id null and it - // stays a standalone document. - const { data: docRow, error: docErr } = await db - .from("documents") - .insert({ - project_id: options?.projectId ?? null, - user_id: userId, - status: "ready", - }) - .select("id") - .single(); - if (docErr || !docRow) { - return { - error: `Failed to record generated document: ${docErr?.message ?? "unknown"}`, - }; - } - const documentId = docRow.id as string; - - const { data: versionRow, error: verErr } = await db - .from("document_versions") - .insert({ - document_id: documentId, - storage_path: key, - source: "generated", - version_number: 1, - filename: filename, - file_type: "docx", - size_bytes: buf.byteLength, - page_count: null, - }) - .select("id") - .single(); - if (verErr || !versionRow) { - return { - error: `Failed to record generated document version: ${verErr?.message ?? "unknown"}`, - }; - } - const versionId = versionRow.id as string; - - await db - .from("documents") - .update({ - current_version_id: versionId, - }) - .eq("id", documentId); - - return { - filename, - download_url: downloadUrl, - document_id: documentId, - version_id: versionId, - version_number: 1, - storage_path: key, - message: `Document '${filename}' has been generated successfully.`, - }; - } catch (e) { - return { error: String(e) }; - } -} - -// --------------------------------------------------------------------------- -// Document version helpers (DOCX tracked-change editing) -// --------------------------------------------------------------------------- - -/** - * Resolve the current .docx bytes for a document, preferring the active - * tracked-changes version if one exists, else the original upload. - */ -export async function loadCurrentVersionBytes( - documentId: string, - db: ReturnType<typeof createServerSupabase>, -): Promise<{ bytes: Buffer; storage_path: string } | null> { - const active = await loadActiveVersion(documentId, db); - if (!active) return null; - const raw = await downloadFile(active.storage_path); - if (!raw) return null; - return { bytes: Buffer.from(raw), storage_path: active.storage_path }; -} - -/** - * Ensure the document has a document_versions row for the current upload. - * Called before writing the first 'assistant_edit' row so the history is - * complete. Idempotent. - */ -export async function runEditDocument(params: { - documentId: string; - userId: string; - edits: EditInput[]; - db: ReturnType<typeof createServerSupabase>; - /** - * If provided, append these edits to the existing turn-scoped version - * (overwrites the file at storagePath and reuses the document_versions - * row) instead of creating a new version. Used to collapse multiple - * edit_document tool calls within a single assistant turn into one - * version. - */ - reuseVersion?: { - versionId: string; - versionNumber: number; - storagePath: string; - }; -}): Promise< - | { - ok: true; - version_id: string; - version_number: number; - storage_path: string; - download_url: string; - annotations: EditAnnotation[]; - errors: { index: number; reason: string }[]; - } - | { ok: false; error: string } -> { - const { documentId, userId, edits, db, reuseVersion } = params; - - const { data: doc } = await db - .from("documents") - .select("id") - .eq("id", documentId) - .single(); - if (!doc) return { ok: false, error: "Document not found." }; - - const activeVersion = await loadActiveVersion(documentId, db); - let versionFilename = - activeVersion?.filename?.trim() || "Untitled document"; - - const current = await loadCurrentVersionBytes(documentId, db); - if (!current) return { ok: false, error: "Could not load document bytes." }; - - const { - bytes: editedBytes, - changes, - errors, - } = await applyTrackedEdits(current.bytes, edits, { author: "Mike" }); - - if (changes.length === 0) { - return { - ok: false, - error: - errors[0]?.reason ?? - "No edits could be applied. Refine context_before/context_after and retry.", - }; - } - - const ab = editedBytes.buffer.slice( - editedBytes.byteOffset, - editedBytes.byteOffset + editedBytes.byteLength, - ) as ArrayBuffer; - - let versionRowId: string; - let newPath: string; - let nextVersionNumber: number; - - if (reuseVersion) { - // Overwrite the existing turn version's file in place. The version - // row, version_number, and current_version_id all already point here. - newPath = reuseVersion.storagePath; - versionRowId = reuseVersion.versionId; - nextVersionNumber = reuseVersion.versionNumber; - await uploadFile( - newPath, - ab, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ); - await db - .from("document_versions") - .update({ - file_type: "docx", - size_bytes: editedBytes.byteLength, - page_count: null, - }) - .eq("id", versionRowId); - } else { - const versionId = crypto.randomUUID().replace(/-/g, ""); - newPath = `documents/${userId}/${documentId}/edits/${versionId}.docx`; - await uploadFile( - newPath, - ab, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ); - - // Per-document sequential number for the new assistant_edit - // version. The counter spans upload + user_upload + assistant_edit - // so the original upload is V1 and the first assistant edit is V2. - const { data: maxRow } = await db - .from("document_versions") - .select("version_number") - .eq("document_id", documentId) - .in("source", ["upload", "user_upload", "assistant_edit"]) - .order("version_number", { ascending: false, nullsFirst: false }) - .limit(1) - .maybeSingle(); - nextVersionNumber = ((maxRow?.version_number as number | null) ?? 1) + 1; - - // Inherit the filename from the most recent prior version so - // user-applied renames carry forward through further edits. Malformed - // legacy rows without a filename get a neutral placeholder, not the - // parent document filename. We intentionally do NOT append "[Edited Vn]" - // — the version number is surfaced separately as a tag in the UI. - const { data: prevRow } = await db - .from("document_versions") - .select("filename, created_at") - .eq("document_id", documentId) - .order("created_at", { ascending: false }) - .limit(1) - .maybeSingle(); - const inheritedFilename = - (prevRow?.filename as string | null)?.trim() || "Untitled document"; - versionFilename = inheritedFilename; - - const { data: versionRow, error: verErr } = await db - .from("document_versions") - .insert({ - document_id: documentId, - storage_path: newPath, - source: "assistant_edit", - version_number: nextVersionNumber, - filename: inheritedFilename, - file_type: "docx", - size_bytes: editedBytes.byteLength, - page_count: null, - }) - .select("id") - .single(); - if (verErr || !versionRow) { - return { ok: false, error: "Failed to record document version." }; - } - versionRowId = versionRow.id as string; - } - - // Insert one row per change - const editRows = changes.map((c) => ({ - document_id: documentId, - version_id: versionRowId, - change_id: c.id, - del_w_id: c.delId ?? null, - ins_w_id: c.insId ?? null, - deleted_text: c.deletedText, - inserted_text: c.insertedText, - context_before: c.contextBefore ?? "", - context_after: c.contextAfter ?? "", - status: "pending" as const, - })); - const { data: insertedEdits, error: editsErr } = await db - .from("document_edits") - .insert(editRows) - .select( - "id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after", - ); - - if (editsErr || !insertedEdits) { - return { ok: false, error: "Failed to record edits." }; - } - - await db - .from("documents") - .update({ - current_version_id: versionRowId, - }) - .eq("id", documentId); - - const annotations: EditAnnotation[] = insertedEdits.map( - (r: { - id: string; - change_id: string; - deleted_text: string; - inserted_text: string; - context_before: string | null; - context_after: string | null; - }) => { - const src = changes.find((c) => c.id === r.change_id); - return { - kind: "edit", - edit_id: r.id, - document_id: documentId, - version_id: versionRowId, - version_number: nextVersionNumber, - change_id: r.change_id, - del_w_id: src?.delId, - ins_w_id: src?.insId, - deleted_text: r.deleted_text ?? "", - inserted_text: r.inserted_text ?? "", - context_before: r.context_before ?? "", - context_after: r.context_after ?? "", - reason: src?.reason, - status: "pending", - }; - }, - ); - - // Persistent, non-expiring permalink. The backend streams fresh bytes - // on each request, so this URL stays valid as long as the file exists. - const resolvedFilename = versionFilename.trim() || "Untitled document.docx"; - const permalink = buildDownloadUrl(newPath, resolvedFilename); - - return { - ok: true, - version_id: versionRowId, - version_number: nextVersionNumber, - storage_path: newPath, - download_url: permalink, - annotations, - errors, - }; -} - -// --------------------------------------------------------------------------- -// Tool dispatch -// --------------------------------------------------------------------------- - -async function readDocumentContent( - docLabel: string, - docStore: DocStore, - write: (s: string) => void, - docIndex?: DocIndex, - db?: ReturnType<typeof createServerSupabase>, - opts?: { emitEvents?: boolean }, -): Promise<string> { - const emitEvents = opts?.emitEvents ?? true; - devLog(`[read_document] called with docLabel="${docLabel}"`); - const docInfo = docStore.get(docLabel); - if (!docInfo) { - devLog( - `[read_document] MISS — docLabel "${docLabel}" not in docStore. Known labels:`, - Array.from(docStore.keys()), - ); - return "Document not found."; - } - devLog( - `[read_document] docInfo: filename="${docInfo.filename}", file_type="${docInfo.file_type}", storage_path="${docInfo.storage_path}"`, - ); - - const documentId = docIndex?.[docLabel]?.document_id; - const emitDocRead = () => { - if (!emitEvents) return; - write( - `data: ${JSON.stringify({ - type: "doc_read", - filename: docInfo.filename, - document_id: documentId, - })}\n\n`, - ); - }; - if (emitEvents) - write( - `data: ${JSON.stringify({ - type: "doc_read_start", - filename: docInfo.filename, - document_id: documentId, - })}\n\n`, - ); - try { - // Prefer the current tracked-changes version (if any) so read_document - // reflects accepted/pending edits rather than the original upload. - let raw: ArrayBuffer | null = null; - let sourcePath = docInfo.storage_path; - if (documentId && db) { - const current = await loadCurrentVersionBytes(documentId, db); - if (current) { - raw = current.bytes.buffer.slice( - current.bytes.byteOffset, - current.bytes.byteOffset + current.bytes.byteLength, - ) as ArrayBuffer; - sourcePath = current.storage_path; - devLog( - `[read_document] using current version path="${sourcePath}" (bytes=${raw.byteLength})`, - ); - } else { - devLog( - `[read_document] loadCurrentVersionBytes returned null for documentId="${documentId}", falling back to original storage_path`, - ); - } - } - if (!raw) { - raw = await downloadFile(docInfo.storage_path); - if (raw) { - devLog( - `[read_document] fallback download from storage_path="${docInfo.storage_path}" (bytes=${raw.byteLength})`, - ); - } - } - if (!raw) { - devLog( - `[read_document] FAILED to download any bytes for docLabel="${docLabel}" (tried path="${sourcePath}")`, - ); - emitDocRead(); - return "Document could not be read."; - } - // Log the first 8 bytes so we can identify real file format regardless - // of the declared file_type. Valid .docx starts with "PK\x03\x04" - // (zip). Legacy .doc starts with "\xD0\xCF\x11\xE0" (OLE/CFB). - // %PDF-1 is a PDF even if mislabeled. Truncated uploads show as all-zero. - { - const head = Buffer.from(raw).subarray(0, 8); - const hex = head.toString("hex"); - const ascii = head.toString("binary").replace(/[^\x20-\x7e]/g, "."); - devLog( - `[read_document] magic bytes hex=${hex} ascii="${ascii}" for filename="${docInfo.filename}"`, - ); - } - let text: string; - if (docInfo.file_type === "pdf") { - text = await extractPdfText(raw); - devLog( - `[read_document] pdf extracted length=${text.length} for filename="${docInfo.filename}"`, - ); - } else if (docInfo.file_type === "docx") { - // Use the same flattening as the edit_document matcher so the - // LLM sees exactly the characters it can anchor against. - text = await extractDocxBodyText(Buffer.from(raw)); - devLog( - `[read_document] docx extractDocxBodyText length=${text.length} for filename="${docInfo.filename}"`, - ); - if (!text) { - devLog( - `[read_document] docx accepted-view extractor returned empty, falling back to mammoth for filename="${docInfo.filename}"`, - ); - const mammoth = await import("mammoth"); - const result = await mammoth.extractRawText({ - buffer: Buffer.from(raw), - }); - text = result.value; - devLog( - `[read_document] docx mammoth fallback length=${text.length} for filename="${docInfo.filename}"`, - ); - } - } else { - devLog( - `[read_document] unknown file_type="${docInfo.file_type}" for filename="${docInfo.filename}", trying mammoth`, - ); - const mammoth = await import("mammoth"); - const result = await mammoth.extractRawText({ - buffer: Buffer.from(raw), - }); - text = result.value; - devLog( - `[read_document] mammoth length=${text.length} for filename="${docInfo.filename}"`, - ); - } - devLog( - `[read_document] DONE filename="${docInfo.filename}" finalTextLength=${text.length} firstChars=${JSON.stringify(text.slice(0, 120))}`, - ); - emitDocRead(); - return text; - } catch (err) { - devLog( - `[read_document] THREW for docLabel="${docLabel}" filename="${docInfo.filename}":`, - err, - ); - if (emitEvents) - write( - `data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`, - ); - return "Document could not be read."; - } -} - -/** - * Build a whitespace-collapsed, lowercased copy of `text`, plus a map from - * each character index in the normalized form back to the corresponding - * index in the original text. Used by `findInDocumentContent` so matches - * are tolerant of case + whitespace variance but can still return the - * exact original excerpt. - */ -function normalizeWithMap(text: string): { norm: string; origIdx: number[] } { - const norm: string[] = []; - const origIdx: number[] = []; - let prevSpace = false; - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - if (/\s/.test(ch)) { - if (!prevSpace) { - norm.push(" "); - origIdx.push(i); - prevSpace = true; - } - } else { - norm.push(ch.toLowerCase()); - origIdx.push(i); - prevSpace = false; - } - } - return { norm: norm.join(""), origIdx }; -} - -function normalizeQuery(q: string): string { - return q.trim().replace(/\s+/g, " ").toLowerCase(); -} - -type TextMatch = { - index: number; - excerpt: string; - context: string; -}; - -function findTextMatches(params: { - text: string; - query: string; - maxResults: number; - contextChars: number; - startIndex?: number; -}): { hits: TextMatch[]; totalMatches: number } { - const { text, query, maxResults, contextChars, startIndex = 0 } = params; - const { norm, origIdx } = normalizeWithMap(text); - const needle = normalizeQuery(query); - const hits: TextMatch[] = []; - let totalMatches = 0; - if (!needle) return { hits, totalMatches }; - - let from = 0; - while (from <= norm.length - needle.length) { - const pos = norm.indexOf(needle, from); - if (pos < 0) break; - const endNormPos = pos + needle.length; - const origStart = origIdx[pos] ?? 0; - const origEnd = - endNormPos - 1 < origIdx.length - ? origIdx[endNormPos - 1] + 1 - : text.length; - if (hits.length < maxResults) { - const ctxStart = Math.max(0, origStart - contextChars); - const ctxEnd = Math.min(text.length, origEnd + contextChars); - hits.push({ - index: startIndex + hits.length, - excerpt: text.slice(origStart, origEnd), - context: - (ctxStart > 0 ? "…" : "") + - text.slice(ctxStart, ctxEnd).replace(/\s+/g, " ").trim() + - (ctxEnd < text.length ? "…" : ""), - }); - } - totalMatches++; - from = pos + Math.max(1, needle.length); - } - - return { hits, totalMatches }; -} - -/** - * Ctrl+F helper. Returns a JSON-serializable result with up to `maxResults` - * hits, each containing the original-text excerpt plus surrounding context. - */ -async function findInDocumentContent(params: { - docLabel: string; - query: string; - maxResults?: number; - contextChars?: number; - docStore: DocStore; - write: (s: string) => void; - docIndex?: DocIndex; - db?: ReturnType<typeof createServerSupabase>; -}): Promise<string> { - const { - docLabel, - query, - maxResults = 20, - contextChars = 80, - docStore, - write, - docIndex, - db, - } = params; - - if (!query || !query.trim()) { - return JSON.stringify({ ok: false, error: "Empty query." }); - } - - const docInfo = docStore.get(docLabel); - if (!docInfo) { - return JSON.stringify({ - ok: false, - error: `Document '${docLabel}' not found.`, - }); - } - - // Announce the search to the UI, then reuse readDocumentContent for its - // fallbacks — but suppress its own doc_read events so the user only sees - // the doc_find block (not a competing doc_read block for the same op). - write( - `data: ${JSON.stringify({ - type: "doc_find_start", - filename: docInfo.filename, - query, - })}\n\n`, - ); - - const text = await readDocumentContent( - docLabel, - docStore, - write, - docIndex, - db, - { emitEvents: false }, - ); - if (!text || text === "Document could not be read.") { - write( - `data: ${JSON.stringify({ - type: "doc_find", - filename: docInfo.filename, - query, - total_matches: 0, - })}\n\n`, - ); - return JSON.stringify({ - ok: false, - filename: docInfo.filename, - error: "Document could not be read.", - }); - } - - const needle = normalizeQuery(query); - if (!needle) { - return JSON.stringify({ - ok: false, - error: "Empty query after normalization.", - }); - } - - const { hits, totalMatches } = findTextMatches({ - text, - query, - maxResults, - contextChars, - }); - - write( - `data: ${JSON.stringify({ - type: "doc_find", - filename: docInfo.filename, - query, - total_matches: totalMatches, - })}\n\n`, - ); - - return JSON.stringify({ - ok: true, - filename: docInfo.filename, - query, - total_matches: totalMatches, - returned: hits.length, - truncated: totalMatches > hits.length, - hits, - }); -} - -export type DocEditedResult = { - filename: string; - document_id: string; - version_id: string; - version_number: number | null; - download_url: string; - annotations: EditAnnotation[]; -}; - -export type TurnEditState = Map< - string, - { versionId: string; versionNumber: number; storagePath: string } ->; - -export type DocCreatedResult = { - filename: string; - download_url: string; - document_id?: string; - version_id?: string; - version_number?: number | null; -}; - -export type DocReplicatedResult = { - /** Filename of the source document being copied. */ - filename: string; - /** How many copies were produced in this single tool call. */ - count: number; - /** One entry per new copy. */ - copies: { - new_filename: string; - document_id: string; - version_id: string; - }[]; -}; - -type CourtlistenerCaseRecord = { - clusterId: number; - caseName: string | null; - citations: string[]; - url: string | null; - pdfUrl: string | null; - dateFiled: string | null; - opinions?: unknown[]; -}; - -type CourtlistenerCaseInput = { - clusterId?: number | null; - caseName?: string | null; - citation?: string | null; - citations?: string[]; - url?: string | null; - pdfUrl?: string | null; - dateFiled?: string | null; - opinions?: unknown[]; -}; - -type CourtlistenerTurnState = { - casesByClusterId: Map<number, CourtlistenerCaseRecord>; -}; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function upsertCourtlistenerCases( - state: CourtlistenerTurnState, - inputs: CourtlistenerCaseInput[], -): CourtlistenerCaseRecord[] { - const records: CourtlistenerCaseRecord[] = []; - for (const input of inputs) { - if (typeof input.clusterId !== "number" || !Number.isFinite(input.clusterId)) { - continue; - } - const clusterId = Math.floor(input.clusterId); - const current = - state.casesByClusterId.get(clusterId) ?? - { - clusterId, - caseName: null, - citations: [], - url: null, - pdfUrl: null, - dateFiled: null, - }; - const nextCitations = [ - ...current.citations, - ...(input.citation ? [input.citation] : []), - ...(input.citations ?? []), - ] - .map(nonEmpty) - .filter((value): value is string => !!value); - const record: CourtlistenerCaseRecord = { - ...current, - caseName: current.caseName ?? nonEmpty(input.caseName), - citations: Array.from(new Set(nextCitations)), - url: current.url ?? nonEmpty(input.url), - pdfUrl: current.pdfUrl ?? nonEmpty(input.pdfUrl), - dateFiled: current.dateFiled ?? nonEmpty(input.dateFiled), - opinions: current.opinions ?? input.opinions, - }; - state.casesByClusterId.set(clusterId, record); - records.push(record); - } - return records; -} - -function caseCitationEventFromRecord( - record: CourtlistenerCaseRecord, -): CaseCitationEvent | null { - if (!record.url) return null; - return { - type: "case_citation", - cluster_id: record.clusterId, - case_name: record.caseName, - citation: record.citations[0] ?? null, - url: record.url, - pdfUrl: record.pdfUrl, - dateFiled: record.dateFiled, - }; -} - -function recordFromUnknown(value: unknown): Record<string, unknown> | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record<string, unknown>) - : null; -} - -function stringField( - record: Record<string, unknown> | null, - key: string, -): string | null { - const value = record?.[key]; - return typeof value === "string" ? value : null; -} - -function numberField( - record: Record<string, unknown> | null, - key: string, -): number | null { - const value = record?.[key]; - return typeof value === "number" && Number.isFinite(value) - ? Math.floor(value) - : null; -} - -function stringArrayField( - record: Record<string, unknown> | null, - key: string, -): string[] { - const value = record?.[key]; - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - -function courtlistenerCaseInputFromFetchedCase( - fallbackClusterId: number, - fetchedCase: unknown, -): CourtlistenerCaseInput { - const record = recordFromUnknown(fetchedCase); - const clusterId = - numberField(record, "clusterId") ?? numberField(record, "id") ?? fallbackClusterId; - return { - clusterId, - caseName: stringField(record, "caseName"), - citations: stringArrayField(record, "citations"), - url: stringField(record, "url"), - pdfUrl: stringField(record, "pdfUrl"), - dateFiled: stringField(record, "dateFiled"), - opinions: Array.isArray(record?.opinions) ? record.opinions : undefined, - }; -} - -function courtlistenerOpinionCount(fetchedCase: unknown): number { - const record = recordFromUnknown(fetchedCase); - return Array.isArray(record?.opinions) ? record.opinions.length : 0; -} - -function courtlistenerOpinionMetadata(raw: unknown) { - const opinion = recordFromUnknown(raw); - if (!opinion) return null; - const text = - stringField(opinion, "text") ?? - (stringField(opinion, "html") - ? stripCaseOpinionHtml(stringField(opinion, "html")!) - : null); - return { - opinion_id: - numberField(opinion, "opinionId") ?? numberField(opinion, "id"), - type: stringField(opinion, "type"), - author: stringField(opinion, "author"), - per_curiam: stringField(opinion, "per_curiam"), - joined_by_str: stringField(opinion, "joined_by_str"), - url: stringField(opinion, "url"), - char_count: text?.length ?? 0, - }; -} - -function courtlistenerFetchedCaseMetadata( - record: CourtlistenerCaseRecord, - opinionCount: number, -) { - return { - cluster_id: record.clusterId, - case_name: record.caseName, - citation: record.citations[0] ?? null, - citations: record.citations, - dateFiled: record.dateFiled, - url: record.url, - pdfUrl: record.pdfUrl, - opinion_count: opinionCount, - opinions: (record.opinions ?? []) - .map(courtlistenerOpinionMetadata) - .filter((opinion): opinion is NonNullable<typeof opinion> => !!opinion), - }; -} - -function stripCaseOpinionHtml(value: string): string { - return value - .replace(/<style[\s\S]*?<\/style>/gi, " ") - .replace(/<script[\s\S]*?<\/script>/gi, " ") - .replace(/<[^>]+>/g, " ") - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/\s+/g, " ") - .trim(); -} - -type CachedCaseOpinionText = { - opinion_id: number | null; - type: string | null; - author: string | null; - url: string | null; - text: string; -}; - -function cachedCaseOpinionTexts( - record: CourtlistenerCaseRecord, -): CachedCaseOpinionText[] { - return (record.opinions ?? []) - .map((raw) => { - const opinion = recordFromUnknown(raw); - if (!opinion) return null; - const text = - stringField(opinion, "text") ?? - (stringField(opinion, "html") - ? stripCaseOpinionHtml(stringField(opinion, "html")!) - : null); - if (!text) return null; - return { - opinion_id: - numberField(opinion, "opinionId") ?? numberField(opinion, "id"), - type: stringField(opinion, "type"), - author: stringField(opinion, "author"), - url: stringField(opinion, "url"), - text, - }; - }) - .filter((opinion): opinion is CachedCaseOpinionText => !!opinion); -} - -function requestedCourtlistenerOpinionIds(args: Record<string, unknown>) { - const rawIds = Array.isArray(args.opinionIds) - ? args.opinionIds - : Array.isArray(args.opinion_ids) - ? args.opinion_ids - : typeof args.opinionId === "number" - ? [args.opinionId] - : typeof args.opinion_id === "number" - ? [args.opinion_id] - : []; - return Array.from( - new Set( - rawIds - .filter((value): value is number => typeof value === "number") - .filter((value) => Number.isFinite(value) && value > 0) - .map((value) => Math.floor(value)), - ), - ); -} - -type FindInCaseArgs = { - clusterId: number | null; - query: string; - maxResults: number; - contextChars: number; -}; - -function parseFindInCaseArgs(args: Record<string, unknown>): FindInCaseArgs { - return { - clusterId: - typeof args.clusterId === "number" && Number.isFinite(args.clusterId) - ? Math.floor(args.clusterId) - : typeof args.cluster_id === "number" && Number.isFinite(args.cluster_id) - ? Math.floor(args.cluster_id) - : null, - query: typeof args.query === "string" ? args.query : "", - maxResults: - typeof args.max_results === "number" - ? Math.max(0, Math.floor(args.max_results)) - : 20, - contextChars: - typeof args.context_chars === "number" - ? Math.max(0, Math.floor(args.context_chars)) - : 160, - }; -} - -function findInCaseSearchSummary( - event: Extract<CourtlistenerToolEvent, { type: "courtlistener_find_in_case" }>, -) { - return { - cluster_id: event.cluster_id, - query: event.query, - total_matches: event.total_matches, - case_name: event.case_name, - citation: event.citation, - error: event.error, - }; -} - -function cachedCaseNotFetchedResult(clusterId: number | null) { - return { - ok: false, - cluster_id: clusterId, - error: - "Case has not been fetched in this turn. Call courtlistener_get_cases first.", - }; -} - -export async function runToolCalls( - toolCalls: ToolCall[], - docStore: DocStore, - userId: string, - db: ReturnType<typeof createServerSupabase>, - write: (s: string) => void, - workflowStore?: WorkflowStore, - tabularStore?: TabularCellStore, - docIndex?: DocIndex, - turnEditState?: TurnEditState, - projectId?: string | null, - courtlistenerState?: CourtlistenerTurnState, - apiKeys?: import("./llm").UserApiKeys, -): Promise<{ - toolResults: unknown[]; - docsRead: { filename: string; document_id?: string }[]; - docsFound: { filename: string; query: string; total_matches: number }[]; - docsCreated: DocCreatedResult[]; - docsReplicated: DocReplicatedResult[]; - workflowsApplied: { workflow_id: string; title: string }[]; - docsEdited: DocEditedResult[]; - courtlistenerEvents: CourtlistenerToolEvent[]; - caseCitationEvents: CaseCitationEvent[]; - mcpEvents: McpToolEvent[]; -}> { - const toolResults: unknown[] = []; - const docsRead: { filename: string; document_id?: string }[] = []; - const docsFound: { - filename: string; - query: string; - total_matches: number; - }[] = []; - const docsCreated: DocCreatedResult[] = []; - const docsReplicated: DocReplicatedResult[] = []; - const workflowsApplied: { workflow_id: string; title: string }[] = []; - const docsEdited: DocEditedResult[] = []; - const courtlistenerEvents: CourtlistenerToolEvent[] = []; - const caseCitationEvents: CaseCitationEvent[] = []; - const mcpEvents: McpToolEvent[] = []; - const courtState: CourtlistenerTurnState = - courtlistenerState ?? - { - casesByClusterId: new Map(), - }; - const groupedFindInCaseSearches = toolCalls - .filter((tc) => tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase) - .map((tc) => { - let rawArgs: Record<string, unknown> = {}; - try { - rawArgs = JSON.parse(tc.function.arguments || "{}"); - } catch { - /* ignore */ - } - const parsed = parseFindInCaseArgs(rawArgs); - return { - cluster_id: parsed.clusterId, - query: parsed.query, - total_matches: 0, - }; - }); - const shouldGroupFindInCase = groupedFindInCaseSearches.length > 1; - let groupedFindInCaseStarted = false; - const groupedFindInCaseEvents: Extract< - CourtlistenerToolEvent, - { type: "courtlistener_find_in_case" } - >[] = []; - - for (const tc of toolCalls) { - let args: Record<string, unknown> = {}; - try { - args = JSON.parse(tc.function.arguments || "{}"); - } catch { - /* ignore */ - } - - if (tc.function.name.startsWith("mcp_")) { - write( - `data: ${JSON.stringify({ - type: "mcp_tool_start", - name: tc.function.name, - })}\n\n`, - ); - const { content, event } = await executeMcpToolCall( - userId, - tc.function.name, - args, - db, - ); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content, - }); - mcpEvents.push(event); - write( - `data: ${JSON.stringify({ - type: "mcp_tool_result", - name: tc.function.name, - connector_name: event.connector_name, - tool_name: event.tool_name, - status: event.status, - error: event.error, - })}\n\n`, - ); - continue; - } - - if (tc.function.name === "read_document") { - const rawDocId = args.doc_id as string; - const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const content = await readDocumentContent( - docId, - docStore, - write, - docIndex, - db, - ); - const filename = docStore.get(docId)?.filename; - const documentId = docIndex?.[docId]?.document_id; - if (filename) docsRead.push({ filename, document_id: documentId }); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: filename - ? `${citationReminder(docId, filename)}\n\n${content}` - : content, - }); - } else if (tc.function.name === "find_in_document") { - const rawDocId = args.doc_id as string; - const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const query = (args.query as string) ?? ""; - const maxResults = - typeof args.max_results === "number" ? args.max_results : undefined; - const contextChars = - typeof args.context_chars === "number" ? args.context_chars : undefined; - const content = await findInDocumentContent({ - docLabel: docId, - query, - maxResults, - contextChars, - docStore, - write, - docIndex, - db, - }); - const filename = docStore.get(docId)?.filename; - if (filename) { - let totalMatches = 0; - try { - const parsed = JSON.parse(content) as { - total_matches?: number; - }; - totalMatches = parsed.total_matches ?? 0; - } catch { - /* ignore — still record the find attempt */ - } - docsFound.push({ - filename, - query, - total_matches: totalMatches, - }); - } - toolResults.push({ role: "tool", tool_call_id: tc.id, content }); - } else if (tc.function.name === "list_documents") { - const list = Array.from(docStore.entries()).map(([doc_id, info]) => ({ - doc_id, - filename: info.filename, - file_type: info.file_type, - })); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(list), - }); - } else if (tc.function.name === "fetch_documents") { - const rawDocIds = (args.doc_ids as string[]) ?? []; - const docIds = rawDocIds.map( - (id) => resolveDocLabel(id, docStore, docIndex) ?? id, - ); - const parts: string[] = []; - for (const docId of docIds) { - const content = await readDocumentContent( - docId, - docStore, - write, - docIndex, - db, - ); - const filename = docStore.get(docId)?.filename ?? docId; - parts.push( - `--- ${filename} (${docId}) ---\n${citationReminder(docId, filename)}\n\n${content}`, - ); - if (docStore.get(docId)) { - const documentId = docIndex?.[docId]?.document_id; - docsRead.push({ filename, document_id: documentId }); - } - } - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: parts.join("\n\n"), - }); - } else if (tc.function.name === "list_workflows") { - const list = workflowStore - ? Array.from(workflowStore.entries()).map(([id, w]) => ({ - id, - title: w.title, - })) - : []; - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(list), - }); - } else if (tc.function.name === "read_workflow") { - const wfId = args.workflow_id as string; - const wf = workflowStore?.get(wfId); - if (wf) { - write( - `data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`, - ); - workflowsApplied.push({ workflow_id: wfId, title: wf.title }); - } - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`, - }); - } else if (tc.function.name === "read_table_cells" && tabularStore) { - const colIndices = args.col_indices as number[] | undefined; - const rowIndices = args.row_indices as number[] | undefined; - - const filteredCols = colIndices?.length - ? tabularStore.columns.filter((_, i) => colIndices.includes(i)) - : tabularStore.columns; - const filteredDocs = rowIndices?.length - ? tabularStore.documents.filter((_, i) => rowIndices.includes(i)) - : tabularStore.documents; - - const label = `${filteredCols.length} ${filteredCols.length === 1 ? "column" : "columns"} × ${filteredDocs.length} ${filteredDocs.length === 1 ? "row" : "rows"}`; - write( - `data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`, - ); - - const lines: string[] = []; - for (const col of filteredCols) { - const colPos = tabularStore.columns.findIndex( - (c) => c.index === col.index, - ); - for (const doc of filteredDocs) { - const rowPos = tabularStore.documents.findIndex( - (d) => d.id === doc.id, - ); - const cell = tabularStore.cells.get(`${col.index}:${doc.id}`); - lines.push( - `[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`, - ); - if (cell?.summary) { - lines.push(`Summary: ${cell.summary}`); - if (cell.flag) lines.push(`Flag: ${cell.flag}`); - if (cell.reasoning) lines.push(`Reasoning: ${cell.reasoning}`); - } else { - lines.push(`(not yet generated)`); - } - lines.push(""); - } - } - - write( - `data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`, - ); - docsRead.push({ filename: label }); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: lines.join("\n") || "No cells found.", - }); - } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.searchCaseLaw) { - const query = typeof args.query === "string" ? args.query : ""; - write( - `data: ${JSON.stringify({ type: "courtlistener_search_case_law_start", query })}\n\n`, - ); - try { - const result = await searchCourtlistenerCaseLaw({ - query: query || undefined, - court: typeof args.court === "string" ? args.court : undefined, - filedAfter: - typeof args.filedAfter === "string" ? args.filedAfter : undefined, - filedBefore: - typeof args.filedBefore === "string" ? args.filedBefore : undefined, - limit: typeof args.limit === "number" ? args.limit : undefined, - apiToken: apiKeys?.courtlistener, - }); - const resultCount = - result && - typeof result === "object" && - Array.isArray((result as { results?: unknown }).results) - ? (result as { results: unknown[] }).results.length - : 0; - const error = - result && - typeof result === "object" && - typeof (result as { error?: unknown }).error === "string" - ? (result as { error: string }).error - : undefined; - const event: CourtlistenerToolEvent = { - type: "courtlistener_search_case_law", - query, - result_count: resultCount, - ...(error ? { error } : {}), - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(result), - }); - } catch (err) { - const event: CourtlistenerToolEvent = { - type: "courtlistener_search_case_law", - query, - result_count: 0, - error: - err instanceof Error ? err.message : "CourtListener search failed.", - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - error: - err instanceof Error - ? err.message - : "CourtListener search failed.", - }), - }); - } - } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.getCases) { - const rawClusterIds = Array.isArray(args.clusterIds) - ? args.clusterIds - : Array.isArray(args.cluster_ids) - ? args.cluster_ids - : typeof args.clusterId === "number" - ? [args.clusterId] - : []; - const clusterIds = Array.from( - new Set( - rawClusterIds - .filter((value): value is number => typeof value === "number") - .filter((value) => Number.isFinite(value) && value > 0) - .map((value) => Math.floor(value)), - ), - ); - write( - `data: ${JSON.stringify({ type: "courtlistener_get_cases_start", cluster_ids: clusterIds })}\n\n`, - ); - try { - const result = await getCourtlistenerCases({ - clusterIds, - db, - apiToken: apiKeys?.courtlistener, - }); - const fetchedCases = - result && - typeof result === "object" && - Array.isArray((result as { cases?: unknown }).cases) - ? (result as { cases: unknown[] }).cases - : []; - fetchedCases.forEach((fetchedCase, index) => { - const clusterId = - courtlistenerCaseInputFromFetchedCase( - clusterIds[index] ?? 0, - fetchedCase, - ).clusterId ?? 0; - if (clusterId) { - write( - `data: ${JSON.stringify({ type: "case_opinions", cluster_id: clusterId, case: fetchedCase })}\n\n`, - ); - } - }); - const caseRecords = upsertCourtlistenerCases( - courtState, - fetchedCases.map((fetchedCase, index) => - courtlistenerCaseInputFromFetchedCase( - clusterIds[index] ?? 0, - fetchedCase, - ), - ), - ); - const opinionCount = fetchedCases.reduce<number>( - (sum, fetchedCase) => sum + courtlistenerOpinionCount(fetchedCase), - 0, - ); - const caseOpinionCountByClusterId = new Map<number, number>(); - fetchedCases.forEach((fetchedCase, index) => { - const clusterId = - courtlistenerCaseInputFromFetchedCase( - clusterIds[index] ?? 0, - fetchedCase, - ).clusterId ?? 0; - if (clusterId) { - caseOpinionCountByClusterId.set( - clusterId, - courtlistenerOpinionCount(fetchedCase), - ); - } - }); - const errors = fetchedCases - .map((fetchedCase) => - stringField(recordFromUnknown(fetchedCase), "error"), - ) - .filter((error): error is string => !!error); - const resultError = - result && - typeof result === "object" && - typeof (result as { error?: unknown }).error === "string" - ? (result as { error: string }).error - : undefined; - const hasMultipleOpinionCase = caseRecords.some( - (record) => - (caseOpinionCountByClusterId.get(record.clusterId) ?? 0) > 1, - ); - const event: CourtlistenerToolEvent = { - type: "courtlistener_get_cases", - cluster_ids: clusterIds, - case_count: fetchedCases.length, - opinion_count: opinionCount, - cases: caseRecords.map((record) => ({ - cluster_id: record.clusterId, - case_name: record.caseName, - citation: record.citations[0] ?? null, - dateFiled: record.dateFiled, - url: record.url, - })), - ...(resultError || errors.length - ? { error: resultError ?? errors.join("; ") } - : {}), - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: !resultError && errors.length === 0, - cluster_ids: clusterIds, - case_count: fetchedCases.length, - opinion_count: opinionCount, - cases: caseRecords.map((record) => - courtlistenerFetchedCaseMetadata( - record, - caseOpinionCountByClusterId.get(record.clusterId) ?? 0, - ), - ), - ...(resultError || errors.length - ? { error: resultError ?? errors.join("; ") } - : {}), - next_required_action: hasMultipleOpinionCase - ? "Opinion text is cached server-side only. Use courtlistener_find_in_case with short 1-3 word keyword probes for relevant passages. At least one fetched case has multiple opinions; if snippets are insufficient, choose the needed opinion_id(s) from the text-free opinion metadata and call courtlistener_read_case with only those IDs. Do not read all opinions unless the question requires it." - : "Opinion text is cached server-side only. Use courtlistener_find_in_case with short 1-3 word keyword probes for relevant passages, or courtlistener_read_case if snippets are insufficient.", - }), - }); - } catch (err) { - const event: CourtlistenerToolEvent = { - type: "courtlistener_get_cases", - cluster_ids: clusterIds, - case_count: 0, - opinion_count: 0, - error: - err instanceof Error - ? err.message - : "CourtListener case fetch failed.", - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - error: - err instanceof Error - ? err.message - : "CourtListener case fetch failed.", - }), - }); - } - } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase) { - const { clusterId, query, maxResults, contextChars } = - parseFindInCaseArgs(args); - if (shouldGroupFindInCase) { - if (!groupedFindInCaseStarted) { - write( - `data: ${JSON.stringify({ - type: "courtlistener_find_in_case_start", - cluster_id: null, - query: "", - searches: groupedFindInCaseSearches, - })}\n\n`, - ); - groupedFindInCaseStarted = true; - } - } else { - write( - `data: ${JSON.stringify({ type: "courtlistener_find_in_case_start", cluster_id: clusterId, query })}\n\n`, - ); - } - - const record = - typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined; - if (!record) { - const payload = cachedCaseNotFetchedResult(clusterId); - const event: CourtlistenerToolEvent = { - type: "courtlistener_find_in_case", - cluster_id: clusterId, - query, - total_matches: 0, - error: payload.error, - }; - if (shouldGroupFindInCase) { - groupedFindInCaseEvents.push(event); - } else { - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - } - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(payload), - }); - continue; - } - - const opinions = cachedCaseOpinionTexts(record); - const hits: Array< - TextMatch & { - opinion_id: number | null; - type: string | null; - author: string | null; - url: string | null; - } - > = []; - let totalMatches = 0; - for (const opinion of opinions) { - const remaining = Math.max(0, maxResults - hits.length); - const result = findTextMatches({ - text: opinion.text, - query, - maxResults: remaining, - contextChars, - startIndex: hits.length, - }); - totalMatches += result.totalMatches; - hits.push( - ...result.hits.map((hit) => ({ - ...hit, - opinion_id: opinion.opinion_id, - type: opinion.type, - author: opinion.author, - url: opinion.url, - })), - ); - } - - const event: CourtlistenerToolEvent = { - type: "courtlistener_find_in_case", - cluster_id: record.clusterId, - query, - total_matches: totalMatches, - case_name: record.caseName, - citation: record.citations[0] ?? null, - }; - if (shouldGroupFindInCase) { - groupedFindInCaseEvents.push(event); - } else { - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - } - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: true, - cluster_id: record.clusterId, - case_name: record.caseName, - citation: record.citations[0] ?? null, - query, - total_matches: totalMatches, - returned: hits.length, - truncated: totalMatches > hits.length, - hits, - }), - }); - } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.readCase) { - const clusterId = - typeof args.clusterId === "number" && Number.isFinite(args.clusterId) - ? Math.floor(args.clusterId) - : typeof args.cluster_id === "number" && - Number.isFinite(args.cluster_id) - ? Math.floor(args.cluster_id) - : null; - write( - `data: ${JSON.stringify({ type: "courtlistener_read_case_start", cluster_id: clusterId })}\n\n`, - ); - - const record = - typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined; - if (!record) { - const payload = cachedCaseNotFetchedResult(clusterId); - const event: CourtlistenerToolEvent = { - type: "courtlistener_read_case", - cluster_id: clusterId, - opinion_count: 0, - error: payload.error, - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(payload), - }); - continue; - } - - const opinions = cachedCaseOpinionTexts(record); - const requestedOpinionIds = requestedCourtlistenerOpinionIds(args); - const selectedOpinions = - requestedOpinionIds.length > 0 - ? opinions.filter( - (opinion) => - typeof opinion.opinion_id === "number" && - requestedOpinionIds.includes(opinion.opinion_id), - ) - : opinions.length === 1 - ? opinions - : []; - if (!selectedOpinions.length) { - const multipleOpinions = opinions.length > 1; - const payload = { - ok: false, - cluster_id: record.clusterId, - case_name: record.caseName, - citations: record.citations, - url: record.url, - dateFiled: record.dateFiled, - opinion_count: opinions.length, - opinions: (record.opinions ?? []) - .map(courtlistenerOpinionMetadata) - .filter( - (opinion): opinion is NonNullable<typeof opinion> => - !!opinion, - ), - error: multipleOpinions - ? "Multiple opinions are available. Call courtlistener_read_case again with the opinionId or opinionIds needed." - : "No matching opinion_id was found for this fetched case.", - }; - const event: CourtlistenerToolEvent = { - type: "courtlistener_read_case", - cluster_id: record.clusterId, - case_name: record.caseName, - citation: record.citations[0] ?? null, - opinion_count: 0, - error: payload.error, - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(payload), - }); - continue; - } - - const event: CourtlistenerToolEvent = { - type: "courtlistener_read_case", - cluster_id: record.clusterId, - case_name: record.caseName, - citation: record.citations[0] ?? null, - opinion_count: selectedOpinions.length, - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: true, - cluster_id: record.clusterId, - case_name: record.caseName, - citations: record.citations, - url: record.url, - dateFiled: record.dateFiled, - opinion_count: opinions.length, - returned_opinion_count: selectedOpinions.length, - opinions: selectedOpinions, - }), - }); - } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.verifyCitations) { - const citations = Array.isArray(args.citations) - ? args.citations.filter( - (value): value is string => typeof value === "string", - ) - : []; - const citationCount = citations.length; - write( - `data: ${JSON.stringify({ type: "courtlistener_verify_citations_start", citation_count: citationCount })}\n\n`, - ); - try { - const result = (await verifyCourtlistenerCitations({ - citations, - db, - apiToken: apiKeys?.courtlistener, - })) as { - citationLinks?: { - clusterId?: number | null; - citation?: string | null; - caseName?: string | null; - dateFiled?: string | null; - pdfUrl?: string | null; - url?: string | null; - markdown?: string; - }[]; - results?: unknown[]; - error?: string; - source?: string; - [key: string]: unknown; - }; - if (Array.isArray(result.citationLinks)) { - const caseRecords = upsertCourtlistenerCases( - courtState, - result.citationLinks.map((link) => ({ - clusterId: link.clusterId, - caseName: link.caseName, - citation: link.citation, - url: link.url, - pdfUrl: link.pdfUrl, - dateFiled: link.dateFiled, - })), - ); - const recordsByClusterId = new Map( - caseRecords.map((record) => [record.clusterId, record]), - ); - result.citationLinks = result.citationLinks.map((link) => { - if (!link.url) return link; - const href = - typeof link.clusterId === "number" - ? `us-case-${link.clusterId}` - : link.url; - const label = [link.caseName, link.citation] - .filter(Boolean) - .join(", "); - const record = - typeof link.clusterId === "number" - ? recordsByClusterId.get(link.clusterId) - : undefined; - if (record) { - const event = caseCitationEventFromRecord(record); - if (event) { - caseCitationEvents.push(event); - write(`data: ${JSON.stringify(event)}\n\n`); - } - } - return { - ...link, - markdown: `[${label || link.url}](${href})`, - }; - }); - } - const rows = - result && - typeof result === "object" && - Array.isArray((result as { results?: unknown }).results) - ? (result as { results: unknown[] }).results - : []; - const matchCount = rows.reduce<number>((count, row) => { - if (!row || typeof row !== "object") return count; - const clusters = (row as { clusters?: unknown }).clusters; - return count + (Array.isArray(clusters) ? clusters.length : 0); - }, 0); - const error = - result && - typeof result === "object" && - typeof (result as { error?: unknown }).error === "string" - ? (result as { error: string }).error - : undefined; - const event: CourtlistenerToolEvent = { - type: "courtlistener_verify_citations", - citation_count: citationCount, - match_count: matchCount, - ...(error ? { error } : {}), - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(result), - }); - } catch (err) { - const event: CourtlistenerToolEvent = { - type: "courtlistener_verify_citations", - citation_count: citationCount, - match_count: 0, - error: - err instanceof Error - ? err.message - : "CourtListener citation lookup failed.", - }; - write(`data: ${JSON.stringify(event)}\n\n`); - courtlistenerEvents.push(event); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - error: - err instanceof Error - ? err.message - : "CourtListener citation lookup failed.", - }), - }); - } - } else if (tc.function.name === "edit_document" && docIndex) { - const rawDocId = args.doc_id as string; - const editsRaw = args.edits as unknown[] | undefined; - const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const docInfo = docStore.get(docId); - const indexed = docIndex?.[docId]; - - const emitEditError = ( - filename: string, - documentId: string, - error: string, - ) => { - // Surface the failure as a failed "Edited" block in the UI - // (start → done-with-error) so it matches the shape the - // success/late-failure paths already use. - write( - `data: ${JSON.stringify({ - type: "doc_edited_start", - filename, - })}\n\n`, - ); - write( - `data: ${JSON.stringify({ - type: "doc_edited", - filename, - document_id: documentId, - version_id: "", - download_url: "", - annotations: [], - error, - })}\n\n`, - ); - }; - - if (!docInfo || !indexed) { - const err = `Document '${docId}' not found in this chat's attachments.`; - emitEditError(docId, indexed?.document_id ?? "", err); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ error: err }), - }); - } else if (!Array.isArray(editsRaw) || editsRaw.length === 0) { - const err = "edits array is required and must not be empty."; - emitEditError(docInfo.filename, indexed.document_id, err); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ error: err }), - }); - } else if (docInfo.file_type !== "docx") { - const err = "edit_document only supports .docx files."; - emitEditError(docInfo.filename, indexed.document_id, err); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ error: err }), - }); - } else { - write( - `data: ${JSON.stringify({ - type: "doc_edited_start", - filename: docInfo.filename, - })}\n\n`, - ); - const edits: EditInput[] = (editsRaw as Record<string, unknown>[]).map( - (e) => ({ - find: String(e.find ?? ""), - replace: String(e.replace ?? ""), - context_before: String(e.context_before ?? ""), - context_after: String(e.context_after ?? ""), - reason: e.reason ? String(e.reason) : undefined, - }), - ); - const reuseVersion = turnEditState?.get(indexed.document_id); - const result = await runEditDocument({ - documentId: indexed.document_id, - userId, - edits, - db, - reuseVersion, - }); - - if (result.ok) { - turnEditState?.set(indexed.document_id, { - versionId: result.version_id, - versionNumber: result.version_number, - storagePath: result.storage_path, - }); - // Keep the chat-local doc label pointed at the latest - // edited version so any follow-up read_document call in - // the same assistant turn reads and cites the same bytes. - if (docIndex[docId]) { - docIndex[docId] = { - ...docIndex[docId], - version_id: result.version_id, - version_number: result.version_number, - }; - } - const currentDocStore = docStore.get(docId); - if (currentDocStore) { - docStore.set(docId, { - ...currentDocStore, - storage_path: result.storage_path, - }); - } - const payload: DocEditedResult = { - filename: docInfo.filename, - document_id: indexed.document_id, - version_id: result.version_id, - version_number: result.version_number, - download_url: result.download_url, - annotations: result.annotations, - }; - docsEdited.push(payload); - write( - `data: ${JSON.stringify({ - type: "doc_edited", - ...payload, - })}\n\n`, - ); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: true, - doc_id: docId, - document_id: indexed.document_id, - version_id: result.version_id, - version_number: result.version_number, - applied: result.annotations.length, - errors: result.errors, - next_required_action: [ - `The edited document remains available as doc_id "${docId}".`, - `Before making factual claims about the edited document's final contents, call read_document with doc_id "${docId}" and base the response on that returned text.`, - `Do not include download links or URLs in your prose response; the edited document card is shown automatically by the UI.`, - `If you describe specific content from the edited document, cite it with [N] markers and a final <CITATIONS> block using doc_id "${docId}".`, - ].join(" "), - }), - }); - } else { - write( - `data: ${JSON.stringify({ - type: "doc_edited", - filename: docInfo.filename, - document_id: indexed.document_id, - version_id: "", - download_url: "", - annotations: [], - error: result.error, - })}\n\n`, - ); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: false, - error: result.error, - }), - }); - } - } - } else if (tc.function.name === "replicate_document" && docIndex) { - const rawDocId = args.doc_id as string; - const requestedFilename = - typeof args.new_filename === "string" && args.new_filename.trim() - ? args.new_filename.trim() - : null; - const requestedCount = - typeof args.count === "number" && Number.isFinite(args.count) - ? Math.max(1, Math.min(20, Math.floor(args.count))) - : 1; - const sourceLabel = - resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const sourceInfo = docStore.get(sourceLabel); - const sourceIndexed = docIndex[sourceLabel]; - const sourceFilename = sourceInfo?.filename ?? rawDocId; - - write( - `data: ${JSON.stringify({ - type: "doc_replicate_start", - filename: sourceFilename, - count: requestedCount, - })}\n\n`, - ); - - const fail = (error: string) => { - write( - `data: ${JSON.stringify({ - type: "doc_replicated", - filename: sourceFilename, - count: requestedCount, - copies: [], - error, - })}\n\n`, - ); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ ok: false, error }), - }); - }; - - if (!sourceInfo || !sourceIndexed) { - fail(`Document '${rawDocId}' not found in this project.`); - } else if (!projectId) { - fail("replicate_document is only available in project chats."); - } else { - try { - // Pull the active version once — every copy gets the - // same starting bytes (with any accepted tracked - // changes rolled in), no point re-fetching per copy. - const active = await loadActiveVersion(sourceIndexed.document_id, db); - const sourcePath = active?.storage_path ?? sourceInfo.storage_path; - const sourcePdfPath = active?.pdf_storage_path ?? null; - const raw = await downloadFile(sourcePath); - const pdfBytes = sourcePdfPath - ? await downloadFile(sourcePdfPath) - : null; - if (!raw) { - fail("Could not read the source document's bytes from storage."); - } else { - // Build N filenames. With count=1 keep the - // pre-existing "(copy)" suffix; with count>1 use - // numbered "(1)", "(2)" suffixes. - const srcExt = sourceInfo.filename.match(/\.[^./\\]+$/)?.[0] ?? ""; - const baseStem = (() => { - if (requestedFilename) { - return requestedFilename.replace(/\.[^./\\]+$/, ""); - } - return sourceInfo.filename.replace(/\.[^./\\]+$/, ""); - })(); - const filenames: string[] = []; - for (let n = 1; n <= requestedCount; n++) { - const suffix = - requestedCount === 1 - ? requestedFilename - ? "" - : " (copy)" - : ` (${n})`; - filenames.push(`${baseStem}${suffix}${srcExt}`); - } - - // Bulk insert N documents in one round-trip. - const docRows = filenames.map((fn) => ({ - project_id: projectId, - user_id: userId, - status: "ready", - })); - const { data: insertedDocs, error: docErr } = await db - .from("documents") - .insert(docRows) - .select("id"); - if (docErr || !insertedDocs || insertedDocs.length === 0) { - fail( - `Failed to record replicated documents: ${docErr?.message ?? "unknown"}`, - ); - } else { - // Preserve the request order so each row pairs - // with the right filename. Supabase returns - // inserted rows in the same order as the - // payload. - const newDocs = (insertedDocs as { id: string }[]).map( - (doc, idx) => ({ - ...doc, - filename: filenames[idx] ?? "Untitled document.docx", - }), - ); - const contentType = - sourceInfo.file_type === "pdf" - ? "application/pdf" - : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - - // Parallel uploads: the doc bytes (and PDF - // rendition if any) for every new copy. - const uploadJobs: Promise<unknown>[] = []; - const newKeys: string[] = []; - const newPdfKeys: (string | null)[] = []; - for (const d of newDocs) { - const key = storageKey(userId, d.id, d.filename); - newKeys.push(key); - uploadJobs.push(uploadFile(key, raw, contentType)); - if (pdfBytes) { - const pdfKey = convertedPdfKey(userId, d.id); - newPdfKeys.push(pdfKey); - uploadJobs.push( - uploadFile(pdfKey, pdfBytes, "application/pdf"), - ); - } else { - newPdfKeys.push(null); - } - } - await Promise.all(uploadJobs); - - // Bulk insert N versions in one round-trip. - const versionRows = newDocs.map((d, idx) => ({ - document_id: d.id, - storage_path: newKeys[idx], - pdf_storage_path: newPdfKeys[idx], - source: "upload", - version_number: 1, - filename: d.filename, - file_type: active?.file_type ?? sourceInfo.file_type, - size_bytes: active?.size_bytes ?? raw.byteLength, - page_count: active?.page_count ?? null, - })); - const { data: insertedVersions, error: verErr } = await db - .from("document_versions") - .insert(versionRows) - .select("id, document_id"); - if ( - verErr || - !insertedVersions || - insertedVersions.length !== newDocs.length - ) { - fail( - `Failed to record replicated document versions: ${verErr?.message ?? "unknown"}`, - ); - } else { - const versionByDocId = new Map<string, string>(); - for (const v of insertedVersions as { - id: string; - document_id: string; - }[]) { - versionByDocId.set(v.document_id, v.id); - } - - // current_version_id has to be a per-row - // value, so a single UPDATE statement - // can't cover all N. Fan out in parallel - // instead of sequential awaits. - await Promise.all( - newDocs.map((d) => - db - .from("documents") - .update({ - current_version_id: versionByDocId.get(d.id), - }) - .eq("id", d.id), - ), - ); - - // Register every copy under a fresh doc-N - // slug so the model can edit/read any of - // them in the same turn. - const existingLabels = new Set(Object.keys(docIndex)); - let nextLabelIdx = 0; - const copies: { - new_filename: string; - document_id: string; - version_id: string; - }[] = []; - const toolPayloadCopies: { - doc_id: string; - document_id: string; - version_id: string; - filename: string; - download_url: string; - }[] = []; - for (let idx = 0; idx < newDocs.length; idx++) { - const d = newDocs[idx]; - const newKey = newKeys[idx]; - const versionId = versionByDocId.get(d.id); - if (!versionId) continue; - while (existingLabels.has(`doc-${nextLabelIdx}`)) - nextLabelIdx++; - const slug = `doc-${nextLabelIdx}`; - existingLabels.add(slug); - docIndex[slug] = { - document_id: d.id, - filename: d.filename, - }; - docStore.set(slug, { - storage_path: newKey, - file_type: sourceInfo.file_type, - filename: d.filename, - }); - copies.push({ - new_filename: d.filename, - document_id: d.id, - version_id: versionId, - }); - toolPayloadCopies.push({ - doc_id: slug, - document_id: d.id, - version_id: versionId, - filename: d.filename, - download_url: buildDownloadUrl(newKey, d.filename), - }); - } - - write( - `data: ${JSON.stringify({ - type: "doc_replicated", - filename: sourceFilename, - count: copies.length, - copies, - })}\n\n`, - ); - docsReplicated.push({ - filename: sourceFilename, - count: copies.length, - copies, - }); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: true, - count: copies.length, - copies: toolPayloadCopies, - }), - }); - } - } - } - } catch (e) { - fail(`replicate_document failed: ${String(e)}`); - } - } - } else if (tc.function.name === "generate_docx") { - const title = args.title as string; - const landscape = !!args.landscape; - devLog( - `[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`, - ); - const previewFilename = `${ - title - .replace(/[^a-zA-Z0-9 _-]/g, "") - .trim() - .slice(0, 64) || "document" - }.docx`; - write( - `data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`, - ); - const result = await generateDocx( - title, - args.sections as unknown[], - userId, - db, - { landscape, projectId: projectId ?? null }, - ); - let newDocLabel: string | null = null; - if ("filename" in result && "download_url" in result) { - const dlFilename = result.filename as string; - const dlUrl = result.download_url as string; - const documentId = (result as { document_id?: string }).document_id; - const versionId = (result as { version_id?: string }).version_id; - const versionNumber = - (result as { version_number?: number }).version_number ?? null; - const storagePath = (result as { storage_path?: string }).storage_path; - - // Register the generated doc in the chat context so - // edit_document (and read_document / find_in_document) - // can act on it within the same assistant turn. New label - // is the next free `doc-N` index. Subsequent turns pick - // it up via the normal attachment/project doc query. - if (documentId && storagePath && docIndex) { - const existingLabels = new Set(Object.keys(docIndex)); - let i = 0; - while (existingLabels.has(`doc-${i}`)) i++; - newDocLabel = `doc-${i}`; - docIndex[newDocLabel] = { - document_id: documentId, - filename: dlFilename, - }; - docStore.set(newDocLabel, { - storage_path: storagePath, - file_type: "docx", - filename: dlFilename, - }); - } - - write( - `data: ${JSON.stringify({ - type: "doc_created", - filename: dlFilename, - download_url: dlUrl, - document_id: documentId, - version_id: versionId, - version_number: versionNumber, - })}\n\n`, - ); - docsCreated.push({ - filename: dlFilename, - download_url: dlUrl, - document_id: documentId, - version_id: versionId, - version_number: versionNumber, - }); - } else { - write( - `data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`, - ); - } - // Surface the chat-local doc label in the tool result so the - // model can pass it as `doc_id` to edit_document / read_document - // / find_in_document in the same turn. Without this the model - // only sees the DB UUID, which isn't valid as a doc_id anchor. - const { download_url, storage_path, ...safeToolResult } = - result as Record<string, unknown>; - const toolResultPayload = newDocLabel - ? { - ...safeToolResult, - doc_id: newDocLabel, - next_required_action: [ - `Before writing your final response, call read_document with doc_id "${newDocLabel}".`, - `Base your description on the generated document's actual returned text, not on memory of what you intended to generate.`, - `Do not include download links, URLs, or markdown links to the document in your prose response; the document card is shown automatically by the UI.`, - `Give a concise description of the generated document and, if you make factual claims about its contents, cite it with [N] markers and a final <CITATIONS> block using doc_id "${newDocLabel}", not any source/template document.`, - ].join(" "), - } - : safeToolResult; - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(toolResultPayload), - }); - } - } - - if (shouldGroupFindInCase && groupedFindInCaseEvents.length > 0) { - const errors = groupedFindInCaseEvents - .map((event) => event.error) - .filter((error): error is string => !!error); - const groupEvent: CourtlistenerToolEvent = { - type: "courtlistener_find_in_case", - cluster_id: null, - query: "", - total_matches: groupedFindInCaseEvents.reduce( - (sum, event) => sum + event.total_matches, - 0, - ), - searches: groupedFindInCaseEvents.map(findInCaseSearchSummary), - ...(errors.length ? { error: errors.join("; ") } : {}), - }; - write(`data: ${JSON.stringify(groupEvent)}\n\n`); - courtlistenerEvents.push(groupEvent); - } - - return { - toolResults, - docsRead, - docsFound, - docsCreated, - docsReplicated, - workflowsApplied, - docsEdited, - courtlistenerEvents, - caseCitationEvents, - mcpEvents, - }; -} - -// --------------------------------------------------------------------------- -// Citation parsing -// --------------------------------------------------------------------------- - -const CITATIONS_BLOCK_RE = /<CITATIONS>\s*([\s\S]*?)\s*<\/CITATIONS>/; -const CITATIONS_OPEN_TAG = "<CITATIONS>"; -const CITATIONS_CLOSE_TAG = "</CITATIONS>"; - -type CitationParseDiagnostics = { - hasBlock: boolean; - rawLength: number; - error: string | null; -}; - -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), - }, - }; - } -} - -function parseCitations(text: string): ParsedCitation[] { - return parseCitationsWithDiagnostics(text).citations; -} - -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; -} - -function createCitationAnnotation( - citation: ParsedCitation, - docIndex: DocIndex, - casesByClusterId?: CourtlistenerTurnState["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, - quotes: citation.quotes, - }; -} - -// --------------------------------------------------------------------------- -// LLM streaming loop -// --------------------------------------------------------------------------- - -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"; -}; - -type AssistantEvent = - | { type: "reasoning"; text: string } - | { 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"; - } -} - -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[]; - annotations: 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(); - 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) => - createCitationAnnotation( - 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, - courtlistenerEvents, - caseCitationEvents, - mcpEvents, - } = await runToolCalls( - toolCalls, - docStore, - userId, - db, - write, - workflowStore, - tabularStore, - docIndex, - turnEditState, - 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 event of courtlistenerEvents) { - events.push(event); - } - for (const event of mcpEvents) { - events.push(event); - } - for (const event of caseCitationEvents) { - events.push(event); - } - - // 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 (isAbortError(err)) { - flushPartialTurn({ emit: false }); - throw new AssistantStreamAbortError(fullText, events); - } - 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) => - createCitationAnnotation( - c, - docIndex, - courtlistenerTurnState.casesByClusterId, - ), - ); - devLog("[chat/stream] final citation annotations", { - hasCitationsBlock: citationDiagnostics.hasBlock, - citationsBlockLength: citationDiagnostics.rawLength, - parseError: citationDiagnostics.error, - parsedCitationCount: parsedCitations.length, - emittedAnnotationCount: citations.length, - usedCustomCitationBuilder: !!buildCitations, - }); - write( - `data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`, - ); - write("data: [DONE]\n\n"); - - return { fullText, events, annotations: citations }; -} - -// --------------------------------------------------------------------------- -// Annotation extraction (for DB save) -// --------------------------------------------------------------------------- - -export function extractAnnotations( - fullText: string, - docIndex: DocIndex, - _events?: ({ type: string } & Record<string, unknown>[]) | unknown[], -): unknown[] { - return parseCitations(fullText).map((c) => - createCitationAnnotation(c, docIndex), - ); -} - -export function stripTransientAssistantEvents(events: AssistantEvent[]) { - return events.filter((event) => event.type !== "case_opinions"); -} - -export function appendCancelledAssistantEvent(events: AssistantEvent[]) { - return [...events, { type: "content" as const, text: "Cancelled by user." }]; -} - -export function buildCancelledAssistantMessage(args: { - fullText: string; - events: AssistantEvent[]; - buildAnnotations: (fullText: string, events: AssistantEvent[]) => unknown[]; -}) { - const events = appendCancelledAssistantEvent( - stripTransientAssistantEvents(args.events), - ); - return { - events, - annotations: args.buildAnnotations(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 { BUILTIN_WORKFLOWS } = await import("./builtinWorkflows"); - const store: WorkflowStore = new Map(); - const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase(); - - // Seed built-ins first - for (const wf of BUILTIN_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; -} diff --git a/backend/src/lib/documentTypes.ts b/backend/src/lib/documentTypes.ts new file mode 100644 index 0000000..7df223b --- /dev/null +++ b/backend/src/lib/documentTypes.ts @@ -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"; + } +} diff --git a/backend/src/lib/llm/tools.ts b/backend/src/lib/llm/tools.ts index f5a383e..a71193b 100644 --- a/backend/src/lib/llm/tools.ts +++ b/backend/src/lib/llm/tools.ts @@ -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; +} diff --git a/backend/src/lib/officeText.ts b/backend/src/lib/officeText.ts new file mode 100644 index 0000000..7478934 --- /dev/null +++ b/backend/src/lib/officeText.ts @@ -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(); +} diff --git a/backend/src/lib/spreadsheet.ts b/backend/src/lib/spreadsheet.ts new file mode 100644 index 0000000..57896fb --- /dev/null +++ b/backend/src/lib/spreadsheet.ts @@ -0,0 +1,109 @@ +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); + + // Map each merged range's top-left (anchor) address to its encoded range so we + // can tag the anchor inline (e.g. `Amount ⟨merged B2:C2⟩`). The covered cells + // stay blank, so the model never reads a covered address (e.g. B1 inside + // A1:C1) as its own value; the tag tells it the anchor spans that range, and + // to cite the whole range for anything in it. + const mergeAnchors = new Map<string, string>(); + for (const m of ws["!merges"] ?? []) { + mergeAnchors.set(XLSX.utils.encode_cell(m.s), XLSX.utils.encode_range(m)); + } + + // 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 }); + let text = sanitizeCellText(cellDisplayText(ws[addr])); + const mergeRange = mergeAnchors.get(addr); + if (mergeRange) { + text = text + ? `${text} ⟨merged ${mergeRange}⟩` + : `⟨merged ${mergeRange}⟩`; + } + 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]; + + 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(); +} diff --git a/backend/src/lib/systemWorkflows.ts b/backend/src/lib/systemWorkflows.ts new file mode 100644 index 0000000..96b5e16 --- /dev/null +++ b/backend/src/lib/systemWorkflows.ts @@ -0,0 +1,1664 @@ +// This file is generated by scripts/build-workflows.js. Do not edit it directly. + +export type SystemWorkflowContributor = { + name: string; + organisation: string | null; + role: string | null; + linkedin: string | null; +}; + +export type SystemWorkflowMetadata = { + title: string; + description: string; + type: "assistant" | "tabular"; + contributors: SystemWorkflowContributor[]; + language: string; + version: string; + practice: string | null; + jurisdictions: string[] | null; +}; + +export type SystemWorkflow = { + id: string; + user_id: null; + is_system: true; + created_at: string; + metadata: SystemWorkflowMetadata; + skill_md: string | null; + columns_config: { index: number; name: string; format?: string; prompt: string; tags?: string[] }[] | null; +}; + +export const SYSTEM_WORKFLOWS: SystemWorkflow[] = [ + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-coc-dd-tabular-review", + "metadata": { + "title": "Change of Control Tabular Review", + "description": "This workflow performs a change of control due diligence review across the selected documents.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Corporate", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Change of Control Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents for change of control provisions and extract structured diligence findings into the columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, notice periods, party names, and defined terms where available.\n- For change of control triggers, capture the exact triggering language and summarize the practical effect in plain English.\n- For consent, termination, option, and financial consequence columns, identify who holds the right or obligation, when it is triggered, and any timing or procedural requirements.\n- If a document does not contain responsive change of control language, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context for legal diligence review.\n- Do not infer a change of control restriction from general assignment, transfer, merger, or affiliate provisions unless the document text supports that connection.\n- Do not invent citations, facts, parties, dates, rights, obligations, or financial consequences.", + "columns_config": [ + { + "index": 0, + "name": "Parties", + "format": "bulleted_list", + "prompt": "Identify all parties to this agreement. For each party state their full legal name and their role (e.g. counterparty, licensor, lender, supplier)." + }, + { + "index": 1, + "name": "Date", + "format": "date", + "prompt": "What is the date of this agreement? If a commencement date differs from the signing date, state both." + }, + { + "index": 2, + "name": "Term", + "format": "text", + "prompt": "What is the term or duration of this agreement? State the start and end dates or the length of the term." + }, + { + "index": 3, + "name": "Change of Control Clause", + "prompt": "Identify and summarize the change of control clause(s) in this document. Quote the exact triggering language and specify what constitutes a 'change of control'." + }, + { + "index": 4, + "name": "Consent Required", + "prompt": "Does a change of control require prior consent from any party? Identify who must consent, the notice period, and any conditions." + }, + { + "index": 5, + "name": "Termination Rights", + "prompt": "What termination rights arise upon a change of control? Who can terminate, and what are the notice requirements?" + }, + { + "index": 6, + "name": "Put/Call Options", + "prompt": "Are there any put or call options triggered by a change of control? Summarize the terms, pricing, and exercise period." + }, + { + "index": 7, + "name": "Financial Implications", + "prompt": "What are the financial implications of a change of control? Include any fees, payments, accelerated obligations, or pricing adjustments." + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-commercial-agreement-tabular-review", + "metadata": { + "title": "Commercial Agreement Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Commercial Agreement Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Parties", + "format": "bulleted_list", + "prompt": "Identify all parties to this agreement. For each party state their full legal name, jurisdiction of incorporation (if stated), and their role in the agreement (e.g. supplier, customer, licensor)." + }, + { + "index": 1, + "name": "Scope of Work", + "format": "text", + "prompt": "Summarise the scope of work or services to be provided under this agreement. What are the key deliverables, obligations, or services? Identify any limitations or exclusions to the scope." + }, + { + "index": 2, + "name": "Amends Earlier Agreement", + "format": "yes_no", + "prompt": "Does this agreement amend, restate, supplement, or replace an earlier agreement? If yes, identify the earlier agreement by name and date." + }, + { + "index": 3, + "name": "Effective Date", + "format": "date", + "prompt": "What is the effective date or commencement date of this agreement? If no explicit date is stated, note when it is deemed to take effect." + }, + { + "index": 4, + "name": "Term", + "format": "text", + "prompt": "What is the duration or term of this agreement? State the initial term length and any conditions that affect the duration." + }, + { + "index": 5, + "name": "Renewal", + "format": "text", + "prompt": "What renewal provisions apply? Specify whether renewal is automatic or requires notice, the renewal period, and any conditions or notice periods required to prevent automatic renewal." + }, + { + "index": 6, + "name": "Pricing", + "format": "text", + "prompt": "What is the pricing structure under this agreement? Identify all fees, rates, charges, and payment terms including currency, payment schedule, and invoicing requirements." + }, + { + "index": 7, + "name": "Price Adjustments", + "format": "text", + "prompt": "Are there any price adjustment mechanisms in this agreement? Identify any indexation, CPI/RPI linkage, benchmarking, volume-based adjustments, or other mechanisms that allow prices to change over the term." + }, + { + "index": 8, + "name": "Penalties for Late Payment", + "format": "text", + "prompt": "What penalties or consequences apply for late payment? Include any interest rates on overdue amounts, suspension rights, or other remedies available to the payee." + }, + { + "index": 9, + "name": "Estimated Contract Value", + "format": "monetary_amount", + "prompt": "What is the total estimated or stated contract value? If no single figure is given, calculate or estimate based on stated rates and term. State the currency and any assumptions made." + }, + { + "index": 10, + "name": "Limitation of Liability", + "format": "text", + "prompt": "What limitations of liability apply? Identify any caps on liability (including how they are calculated), exclusions of consequential or indirect loss, and any carve-outs from the cap (e.g. fraud, death, IP infringement)." + }, + { + "index": 11, + "name": "IP Ownership and Licensing", + "format": "text", + "prompt": "How is intellectual property ownership and licensing addressed? Identify who owns pre-existing IP, who owns newly created IP, and what licences are granted to each party. Note any restrictions on use." + }, + { + "index": 12, + "name": "Change of Control", + "format": "text", + "prompt": "Is there a change of control provision? If so, describe what constitutes a change of control, whether consent is required, and what rights (e.g. termination, assignment) are triggered." + }, + { + "index": 13, + "name": "Force Majeure", + "format": "text", + "prompt": "Summarise the force majeure clause. What events qualify, what obligations are suspended, how long must the event persist before termination is permitted, and what notice is required?" + }, + { + "index": 14, + "name": "Termination Rights", + "format": "text", + "prompt": "What are the termination rights of each party? Identify termination for convenience (including notice period), termination for cause (including cure periods), and the consequences of termination (e.g. payment obligations, survival of terms)." + }, + { + "index": 15, + "name": "Liquidated Damages", + "format": "text", + "prompt": "Are there any liquidated damages provisions? If so, identify what triggers them, the applicable rate or formula, any cap on aggregate liquidated damages, and whether they are the exclusive remedy." + }, + { + "index": 16, + "name": "Governing Law", + "format": "text", + "prompt": "What governing law applies to this agreement? State the jurisdiction and any specific legal system referenced." + }, + { + "index": 17, + "name": "Dispute Resolution", + "format": "text", + "prompt": "How are disputes resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen forum or seat, any escalation or mediation steps required before formal proceedings, and the language of proceedings." + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-commercial-lease-review", + "metadata": { + "title": "Commercial Lease Review", + "description": "Review the uploaded commercial lease and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Real Estate", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Commercial Lease Review\n\n## Instructions\n\nReview the uploaded commercial lease and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — reinstatement obligation is uncapped\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Break Rights | High — Clause [x] imposes vacant possession as a break condition with no cure period. The lessee-specific checklist point indicates the break is fragile and may be lost on a technicality. | For the lessee, remove vacant possession as a break condition or add a cure period and express the condition narrowly. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the premises. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, premises description, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Lessor or Lessee).\n\n| Issue | General | Lessor | Lessee |\n| --- | --- | --- | --- |\n| Parties and Premises | Identify landlord, tenant, guarantor, property, premises, title references, and included or excluded areas. Flag incorrect entities, missing plans, or premises description inconsistent with title. | | |\n| Term | State commencement, expiry, rent commencement, and any conditions precedent. | Flag renewal rights or holding-over provisions that limit the landlord's ability to recover the premises. | Flag uncertain commencement mechanics, no renewal clarity, or landlord-only discretion over start conditions. |\n| Rent and Payments | Confirm rent quantum, payment dates, and deposit mechanics. | Flag unclear payment mechanics, weak late-payment provisions, or rent-free periods exceeding what was agreed. | Flag unclear payment obligations, high default interest, or missing rent-free mechanics. |\n| Rent Review | Identify review dates, mechanism, assumptions, disregards, and dispute process. | Flag review mechanics too favorable to the tenant, downward review provisions, or no upward adjustment. | Flag upward-only review, no cap, aggressive assumptions, or tenant-unfriendly dispute process. |\n| Service Charge and Operating Costs | Identify tenant contribution scope, cap mechanics, audit rights, and reconciliation process. | Flag cost exclusions too broad, caps too low, or audit rights creating undue operational burden. | Flag broad pass-throughs, no cap, capital expenditure exposure, or weak audit rights. |\n| Use | State permitted use, prohibited uses, trading obligations, and change-of-use restrictions. | Flag permitted use too broad or absence of continuous trading obligation. | Flag narrow use restriction, continuous trading obligations, or restrictions inconsistent with operations. |\n| Repairs and Maintenance | Identify landlord and tenant repair obligations, condition standard, and schedule of condition. | Flag limited tenant repairing obligations, repair gaps, or yielding-up conditions below acceptable standard. | Flag full repairing obligation extending to pre-existing defects or structural issues outside tenant control. |\n| Alterations and Fit-Out | Identify consent requirements and reinstatement obligations. | Flag broad alteration rights without landlord consent or no reinstatement obligation. | Flag absolute consent rights, broad reinstatement obligations, or unclear fit-out approval timing. |\n| Assignment, Underletting, and Sharing Occupation | Identify transfer restrictions, consent tests, and group sharing rights. | Flag broad permitted transfer rights allowing assignment to unsuitable tenants without approval. | Flag absolute assignment bans, excessive consent conditions, or no group sharing rights. |\n| Insurance and Damage | Identify who insures, insured risks, rent suspension, and termination rights on damage. | Flag tenant insurance obligations too narrow or absence of rent suspension protection. | Flag no rent suspension on damage, uninsured risk exposure, or no termination right after prolonged damage. |\n| Break Rights | Identify break dates, notice periods, and conditions. | Flag broadly exercisable break conditions allowing tenant exit without adequate penalty. | Flag fragile break conditions, strict vacant possession tests, or excessive preconditions. |\n| Default and Remedies | Identify default events, cure periods, and enforcement rights. | Flag long cure periods, high default thresholds, or limited remedies that delay enforcement. | Flag short cure periods, broad default triggers, or landlord self-help without adequate notice. |\n| Compliance Obligations | Identify which party bears responsibility for regulatory compliance. | Flag compliance gaps exposing the landlord to regulatory liability. | Flag tenant responsibility for landlord works, historic contamination, or compliance beyond the demised premises. |\n| Security Package | Identify security type, quantum, and release conditions. | Flag security inadequate for the tenant's covenant strength or release mechanics too easy to trigger. | Flag excessive security requirements, no step-down on release, or unclear deposit return mechanics. |\n| Rights Reserved and Easements | Identify reserved landlord rights, access arrangements, and tenant rights over common parts. | Flag insufficient reserved rights limiting the landlord's ability to manage the building. | Flag broad landlord entry or disruption rights, or missing rights needed for the tenant's permitted use. |\n| End of Term | Identify yielding-up condition, reinstatement obligations, and dilapidations process. | Flag inadequate reinstatement obligations, unclear yielding-up conditions, or weak dilapidations recovery. | Flag overly broad reinstatement obligations or handback conditions requiring a better standard than at commencement. |\n| Governing Law and Dispute Resolution | State governing law, court forum, expert determination process, and mandatory dispute procedures. Flag ambiguous forum, missing expert determination terms, or inadequate dispute mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-commercial-lease-tabular-review", + "metadata": { + "title": "Commercial Lease Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Real Estate", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Commercial Lease Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Landlord", + "format": "text", + "prompt": "Who is the landlord under this lease? State the full legal name, jurisdiction of incorporation or registration (if applicable), and any registered address or title number stated." + }, + { + "index": 1, + "name": "Tenant", + "format": "text", + "prompt": "Who is the tenant under this lease? State the full legal name, jurisdiction of incorporation or registration (if applicable), and any registered address stated." + }, + { + "index": 2, + "name": "Guarantor", + "format": "text", + "prompt": "Is there a guarantor under this lease? If so, state the guarantor's full legal name and the scope of the guarantee (e.g. full guarantee of the tenant's obligations, or limited to specific obligations). If there is no guarantor, state this explicitly." + }, + { + "index": 3, + "name": "Premises", + "format": "text", + "prompt": "Describe the premises demised under this lease. Include the address, floor(s), unit reference, net internal area (if stated), and any areas included or excluded from the demise (e.g. common parts, roof, structure, car parking)." + }, + { + "index": 4, + "name": "Date of Lease", + "format": "date", + "prompt": "What is the date of this lease? If the lease is undated or if the term commencement date differs from the execution date, note both." + }, + { + "index": 5, + "name": "Term", + "format": "text", + "prompt": "What is the contractual term of this lease? State the length of the term and the term commencement and expiry dates." + }, + { + "index": 6, + "name": "Rent", + "format": "monetary_amount", + "prompt": "What is the initial annual rent payable under this lease? State the amount, the currency, the payment frequency (e.g. quarterly in advance), and the payment dates. Note any rent-free period or initial concessionary rent." + }, + { + "index": 7, + "name": "Rent Review", + "format": "text", + "prompt": "Are there rent review provisions? If so, state the review dates or frequency, the review mechanism (e.g. open market rent review, RPI/CPI indexation, fixed uplift), whether the review is upward-only, any assumptions and disregards applicable to an open market review, and the dispute resolution mechanism if the parties cannot agree the reviewed rent." + }, + { + "index": 8, + "name": "Service Charge", + "format": "text", + "prompt": "Is the tenant liable for a service charge? If so, describe what costs are included within the service charge, the tenant's apportionment or percentage share, any cap on the service charge, and how the service charge is administered and reconciled." + }, + { + "index": 9, + "name": "Insurance", + "format": "text", + "prompt": "What are the insurance obligations under this lease? State who insures (landlord or tenant), what risks must be insured, who bears the insurance premium cost, and the tenant's obligations in respect of the landlord's insurance (e.g. not to vitiate the policy, to pay the premium as additional rent)." + }, + { + "index": 10, + "name": "Permitted Use", + "format": "text", + "prompt": "What is the permitted use of the premises under this lease? State the use class or specific use permitted and identify any restrictions on use. Note whether the landlord's consent is required to change use and on what basis consent may be withheld." + }, + { + "index": 11, + "name": "Repair & Maintenance", + "format": "text", + "prompt": "Who is responsible for repair and maintenance of the premises? Describe the extent of the tenant's repairing obligation (e.g. full repairing, internal repairing only, subject to a schedule of condition). State the landlord's repairing obligations, if any, in respect of the structure, exterior, or common parts." + }, + { + "index": 12, + "name": "Alterations", + "format": "text", + "prompt": "What alterations may the tenant make to the premises? Distinguish between structural and non-structural alterations. Is landlord consent required, and if so on what basis may it be withheld? Must the tenant reinstate alterations at the end of the term?" + }, + { + "index": 13, + "name": "Assignment & Subletting", + "format": "text", + "prompt": "What rights does the tenant have to assign or sublet the premises? State whether assignment and subletting are permitted with landlord consent, on what grounds consent may be withheld, any conditions to be satisfied (e.g. an authorised guarantee agreement on assignment, rent at no less than the passing rent on subletting), and whether any dealings are prohibited outright." + }, + { + "index": 14, + "name": "Break Rights", + "format": "text", + "prompt": "Are there any break rights in this lease? If so, identify who holds the break right (landlord, tenant, or both), the break date(s), the notice period and form required to exercise the break, and any pre-conditions to effective exercise (e.g. no material breach, vacant possession, payment of all sums due)." + }, + { + "index": 15, + "name": "Security of Tenure", + "format": "yes_no", + "prompt": "Does the tenant have statutory security of tenure (e.g. under the Landlord and Tenant Act 1954 in England and Wales, or equivalent legislation in another jurisdiction)? Answer Yes if the lease is contracted in or benefits from security of tenure. Answer No if the lease has been contracted out or if security of tenure does not apply. State the basis for your answer." + }, + { + "index": 16, + "name": "Dilapidations", + "format": "text", + "prompt": "What dilapidations obligations apply at the end of the term? Describe the tenant's yield-up obligations (e.g. to deliver the premises in repair, to reinstate alterations, to redecorate). Is there a schedule of condition limiting the tenant's liability? Note any dilapidations cap or other limitation on the landlord's claim." + }, + { + "index": 17, + "name": "Rent Deposit", + "format": "monetary_amount", + "prompt": "Is a rent deposit required? If so, state the amount, the period for which it is held, the conditions under which the landlord may draw on it, and the circumstances in which it is returned to the tenant." + }, + { + "index": 18, + "name": "Forfeiture & Termination", + "format": "text", + "prompt": "What are the landlord's forfeiture or termination rights? Identify the events that entitle the landlord to forfeit the lease (e.g. non-payment of rent after a grace period, material breach of covenant, insolvency) and any notice requirements before forfeiture can be exercised." + }, + { + "index": 19, + "name": "Governing Law", + "format": "text", + "prompt": "What governing law applies to this lease and which courts have jurisdiction over disputes?" + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-corporate-approvals-review", + "metadata": { + "title": "Corporate Approvals Review", + "description": "Review the uploaded transaction document, resolution, authority package, or corporate action to determine whether the relevant company approvals appear complete, internally cons...", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Corporate", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Corporate Approvals Review\n\n## Instructions\n\nReview the uploaded transaction document, resolution, authority package, or corporate action to determine whether the relevant company approvals appear complete, internally consistent, and supported by the company's corporate records.\n\nBefore producing the review, ask the user to upload or identify the available corporate records to check against. Request the relevant records, including as applicable:\n\n- certificate of incorporation, certificate of formation, or equivalent constitutional filing\n- memorandum and articles, articles of association, bylaws, operating agreement, constitution, or equivalent governing document\n- register of directors, board register, officer register, or equivalent management records\n- register of shareholders, register of members, stock ledger, cap table, or equivalent ownership records\n- board minutes, written resolutions, consents, committee resolutions, shareholder resolutions, or member approvals\n- shareholder agreement, investors' rights agreement, voting agreement, operating agreement, or other approval-rights document\n- incumbency certificate, secretary's certificate, specimen signatures, powers of attorney, delegated authority matrix, or signing authority evidence\n- relevant filings, good standing certificates, prior approvals, or transaction-specific authority documents\n\nIf records are missing, do not assume they exist. Identify what is missing and explain how that limits the review.\n\nProduce a concise Markdown table with exactly these columns:\n\n- Issue\n- Records Checked\n- Finding\n- Risk\n- Recommended Action\n\nUse these risk ratings in the **Risk** column:\n\n- Low: approval position appears standard or no material issue was identified from the records provided.\n- Medium: a gap, ambiguity, or missing supporting record should be clarified before completion.\n- High: a material approval, authority, capacity, or consistency issue may affect execution, validity, enforceability, or closing.\n- Critical: an apparent absence of required approval or authority may block signing or completion unless resolved.\n\nReview the uploaded materials for these categories:\n\n| Category | What to Check |\n| --- | --- |\n| Company Identity and Capacity | Check that the company's legal name, registration number, jurisdiction, entity type, capacity, and status match the corporate records and transaction documents. |\n| Constitutional Authority | Check whether the governing documents permit the transaction, execution of documents, borrowing, guarantees, security, share actions, asset disposals, or other relevant corporate action. |\n| Board Composition and Authority | Check current directors or managers against the board register, appointment records, quorum requirements, conflicts rules, voting thresholds, and authority to approve the transaction. |\n| Shareholder or Member Approvals | Check whether shareholder, member, class, investor, reserved matter, or special approval is required by law, governing documents, shareholder agreements, or other approval-rights documents. |\n| Signing Authority | Check signatories against incumbency records, delegated authority, resolutions, powers of attorney, secretary's certificates, and execution blocks. |\n| Approval Documents | Check board minutes, written resolutions, consents, certificates, and approval packs for correct parties, dates, quorum, votes, conflicts, recitals, authorized documents, and authorized signatories. |\n| Registers and Ownership Records | Check shareholder/member registers, cap tables, stock ledgers, and transfer records for consistency with approvals, voting thresholds, class rights, and parties entitled to approve. |\n| Transaction Document Consistency | Check that the documents approved match the documents being signed, including names, dates, document titles, parties, transaction description, limits, conditions, and schedules. |\n| Reserved Matters and Consent Rights | Check shareholder agreements, investor rights documents, financing documents, or other contracts for veto rights, consent rights, notice requirements, or approval thresholds. |\n| Filings and Ancillary Steps | Check whether filings, good standing certificates, registers, notices, public records updates, share certificates, or post-completion corporate records are required. |\n| Drafting and Record Inconsistencies | Flag inconsistent entity names, officer or director names, dates, document titles, approval thresholds, defined terms, numbering, cross-references, and conflicts between records. |\n\nFor each issue, cite the relevant corporate record or transaction document in the **Records Checked** column. In the **Finding** column, state what the records show and whether they support the proposed action. In the **Recommended Action** column, provide a specific cure, confirmation, document request, approval step, or drafting correction.\n\nIf no material approval issues are found, include a row stating that no material approval issue was identified based on the records provided, and separately identify any records that were not provided or assumptions that remain open.\n\nKeep the response focused on approval, authority, capacity, signing authority, and record consistency. Do not provide a broad commercial contract review unless the user asks for one.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-cp-checklist", + "metadata": { + "title": "Draft CP Checklist", + "description": "Review the uploaded credit agreement or financing document and generate a comprehensive Conditions Precedent (CP) checklist.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Draft Conditions Precedent Checklist\n\nReview the uploaded credit agreement or financing document and generate a comprehensive Conditions Precedent (CP) checklist.\n\nYou 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\nStructure 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\nUse the table field in the section object (not content) for each category's rows.\n\n## Result Table Format\n\nThe document contains one section per category, each with a table in this format:\n\n### Corporate\n\n| Index | Clause Number | Clause | Status |\n| --- | --- | --- | --- |\n| 1 | Cl. 4.1(a)(i) | Certificate of incorporation and constitutional documents of the borrower |   |\n| 2 | Cl. 4.1(a)(ii) | Board resolution authorising execution of the finance documents and approving the terms of the transaction |   |\n| 3 | Cl. 4.1(a)(iii) | Specimen signatures of all authorised signatories |   |\n\n### Legal\n\n| Index | Clause Number | Clause | Status |\n| --- | --- | --- | --- |\n| 1 | Cl. 4.1(c)(i) | Legal opinion from counsel to the borrower in form and substance satisfactory to the agent |   |\n| 2 | Cl. 4.1(c)(ii) | Legal opinion from counsel to the arrangers as to the laws of the relevant jurisdiction |   |\n\nBefore 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).", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-credit-agreement-tabular-review", + "metadata": { + "title": "Credit Agreement Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Finance", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Credit Agreement Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Lenders", + "format": "bulleted_list", + "prompt": "Identify all lenders (or the lender syndicate) named in this agreement. For each, state their full legal name and role (e.g. mandated lead arranger, original lender, agent bank)." + }, + { + "index": 1, + "name": "Borrowers", + "format": "bulleted_list", + "prompt": "Identify all borrowers named in this agreement, including their full legal name and jurisdiction of incorporation." + }, + { + "index": 2, + "name": "Guarantors", + "format": "bulleted_list", + "prompt": "Identify all guarantors named in this agreement, including their full legal name and the scope of their guarantee obligation." + }, + { + "index": 3, + "name": "Other Parties", + "format": "bulleted_list", + "prompt": "Identify any other material parties to this agreement (e.g. facility agent, security agent, hedge counterparties, issuing bank). State their name and role." + }, + { + "index": 4, + "name": "Date of Agreement", + "format": "date", + "prompt": "What is the date of this credit agreement?" + }, + { + "index": 5, + "name": "Facility", + "format": "bulleted_list", + "prompt": "List each facility available under this agreement (e.g. Revolving Credit Facility, Term Loan A, Term Loan B, Term Loan C). For each, state the facility type, tranche name, and any key structural features." + }, + { + "index": 6, + "name": "Amount", + "format": "monetary_amount", + "prompt": "What is the total committed amount available under this agreement across all facilities? State the amount, currency, and breakdown by tranche if applicable." + }, + { + "index": 7, + "name": "Purpose", + "format": "text", + "prompt": "What is the stated purpose for which borrowings under this agreement may be used? Identify any restrictions on use of proceeds." + }, + { + "index": 8, + "name": "Interest", + "format": "text", + "prompt": "What interest rate applies to borrowings under this agreement? Identify the applicable rate (e.g. SOFR, EURIBOR, base rate), the margin, any margin ratchet mechanism, and how interest periods are structured." + }, + { + "index": 9, + "name": "Commitment Fee", + "format": "text", + "prompt": "Is there a commitment fee or utilisation fee? If so, state the applicable rate, how it is calculated, and on what basis (e.g. undrawn commitment, average utilisation)." + }, + { + "index": 10, + "name": "Repayment Schedule", + "format": "text", + "prompt": "Summarise the repayment schedule for each facility. Identify whether repayment is by scheduled instalments or bullet repayment, and state the repayment dates and amounts where specified." + }, + { + "index": 11, + "name": "Maturity", + "format": "date", + "prompt": "What is the final maturity date of the facilities under this agreement? If different facilities have different maturities, state each." + }, + { + "index": 12, + "name": "Security", + "format": "bulleted_list", + "prompt": "What security is granted or required to be granted under this agreement? List each class of security (e.g. share pledges, fixed and floating charges, real estate mortgages, account pledges) and the assets or entities over which security is taken." + }, + { + "index": 13, + "name": "Guarantees", + "format": "bulleted_list", + "prompt": "What guarantee obligations are given under or in connection with this agreement? Identify the guarantors, the scope of the guarantee, and any limitations (e.g. up-stream guarantee limitations, guarantor coverage test)." + }, + { + "index": 14, + "name": "Financial Covenants", + "format": "bulleted_list", + "prompt": "What financial covenants are included in this agreement? For each covenant identify the metric (e.g. leverage ratio, interest cover, cashflow cover), the applicable test, the testing frequency, and any equity cure rights." + }, + { + "index": 15, + "name": "Events of Default", + "format": "bulleted_list", + "prompt": "List the events of default under this agreement. For each, note any grace periods, materiality thresholds, or cross-default provisions." + }, + { + "index": 16, + "name": "Assignment", + "format": "text", + "prompt": "What restrictions or permissions apply to assignment or transfer of rights under this agreement? Identify restrictions on lender transfers (e.g. white/blacklists, borrower consent) and on borrower assignment." + }, + { + "index": 17, + "name": "Change of Control", + "format": "text", + "prompt": "Is there a change of control provision? If so, what constitutes a change of control, what obligations does it trigger (e.g. mandatory prepayment, cancellation, lender consent), and is there any cure period?" + }, + { + "index": 18, + "name": "Prepayment Fee", + "format": "text", + "prompt": "Are there any prepayment fees, make-whole premiums, or soft-call protections? If so, state the applicable fee, the period during which it applies, and any exceptions (e.g. prepayment from insurance proceeds or asset disposal)." + }, + { + "index": 19, + "name": "Governing Law", + "format": "text", + "prompt": "What governing law applies to this agreement? State the jurisdiction and any specific legal system referenced." + }, + { + "index": 20, + "name": "Dispute Resolution", + "format": "text", + "prompt": "How are disputes resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen forum or seat, and any submission to jurisdiction provisions." + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-credit-summary", + "metadata": { + "title": "Credit Agreement Review", + "description": "Review the uploaded credit agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Finance", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Credit Agreement Review\n\n## Instructions\n\nReview the uploaded credit agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — covenant headroom is tight\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause or schedule references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Financial Covenants | High — Clause [x] sets the leverage covenant at [x]x with quarterly testing and no equity cure right. The borrower-specific checklist point indicates limited headroom and no remedy for a technical breach. | For the borrower, widen the covenant threshold, add an equity cure right, and reduce testing frequency to semi-annual. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the facility amount. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, amounts, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Borrower or Lender).\n\n| Issue | General | Borrower | Lender |\n| --- | --- | --- | --- |\n| Parties | Identify all lenders, borrowers, guarantors, agents, and other finance parties with full legal names, roles, and jurisdictions. Flag missing or incorrect entities, unclear agent roles, missing authority confirmations, or transfer mechanics that could result in unknown lenders. | | |\n| Guarantors | Identify guarantors, guarantee scope, limits, and coverage requirements. | Flag uncapped guarantees, missing limits, or upstream guarantee concerns exposing the borrower group. | Flag weak guarantor coverage, missing entities, or guarantees not covering the full facility. |\n| Other Parties | Identify facility agent, security agent, arrangers, issuing banks, and other material parties. | Flag unclear agent roles or mechanics creating additional obligations on the borrower. | Flag inconsistent secured party mechanics or missing parties affecting security validity. |\n| Date of Agreement | State signing date, effective date, and conditions to effectiveness. Flag inconsistent or unclear dates or effectiveness conditions. | | |\n| Facilities | List each facility, type, tranche, availability, and key structural features. | Flag excessive lender discretion, unclear facility purpose, or mismatched terms restricting drawdown. | Flag unclear facility structure or purpose creating enforcement ambiguity. |\n| Amount | State total commitments, currencies, tranche amounts, and accordion or incremental facilities. | Flag unclear currency exposure, uncapped accordion provisions without borrower consent, or inconsistent commitment figures. | Flag uncapped increase mechanics or dilutive accordion provisions not agreed at signing. |\n| Purpose | Summarize permitted use of proceeds and restrictions. | Flag vague purpose wording restricting flexibility or creating sanctions risk. | Flag broad purpose language permitting restricted payment leakage or unapproved use of proceeds. |\n| Interest | Identify reference rate, margin, floors, fallback rates, interest periods, and default interest. | Flag high margins, aggressive floors, benchmark fallback gaps, or high default interest. | Flag unclear fallback rate mechanics or interest calculation ambiguities. |\n| Commitment Fee | State commitment, utilisation, ticking, arrangement, agency, and other fees. | Flag hidden fees, fees payable after cancellation, or unclear calculation basis. | Flag missing fee provisions or fee timing delaying recovery. |\n| Repayment Schedule | Summarize amortisation, scheduled repayments, bullet payments, and cash sweep. | Flag aggressive amortisation, unclear prepayment application, or cash sweep uncertainty. | Flag bullet repayments without adequate covenant or security protection. |\n| Maturity | State final maturity date and any extension options. | Flag short maturity, lender-only extension discretion, or mismatch with the business plan. | Flag unclear extension conditions or borrower-controlled extension mechanics. |\n| Security | Identify security package, assets, entities, perfection steps, and post-closing obligations. | Flag all-asset security beyond deal scope, onerous post-closing steps, or missing release mechanics on disposal. | Flag gaps in the security package, missing perfection steps, or inadequate post-closing obligations. |\n| Guarantees | Summarize guarantee obligations, guarantors, limitations, and release triggers. | Flag broad all-monies language, unlimited guarantees, or no release mechanics after repayment or disposal. | Flag guarantee gaps, weak limitations, or no coverage test requirements. |\n| Financial Covenants | Identify covenant metrics, thresholds, testing frequency, cure rights, and reporting. | Flag tight headroom, frequent testing, no equity cure, or ambiguous EBITDA adjustments. | Flag loose covenant thresholds or wide EBITDA adjustments obscuring true financial performance. |\n| Events of Default | Summarize default triggers, grace periods, materiality thresholds, cross-default, and MAE. | Flag low materiality thresholds, no cure periods, broad cross-default, or subjective MAE defaults. | Flag weak default triggers, long cure periods, or materiality thresholds delaying enforcement. |\n| Assignment | Summarize lender transfer rights, borrower consent mechanics, and disqualified institutions. | Flag free transfers to competitors, distressed investors, or lenders on a borrower blacklist. | Flag consent mechanics giving the borrower excessive veto over lender transfers. |\n| Change of Control | Identify triggers and resulting prepayment, cancellation, or default consequences. | Flag broad triggers, no cure period, or definitions catching ordinary-course ownership changes. | Flag narrow change of control definitions missing material ownership shifts. |\n| Prepayment Fee | Identify make-whole, soft-call, premium periods, and exceptions. | Flag excessive fees, long premium periods, or unclear exceptions for mandatory prepayments. | Flag exceptions allowing free prepayment during premium periods. |\n| Governing Law and Dispute Resolution | State governing law, jurisdiction, forum, and service of process. Flag unclear or ambiguous governing law, jurisdiction, or service mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-draft-issues-list", + "metadata": { + "title": "Draft Issues List", + "description": "Review the uploaded agreement and draft a comprehensive issues list from the perspective of the party represented by the user/client.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Draft Issues List\n\n## Instructions\n\nReview the uploaded agreement and draft a comprehensive issues list from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before drafting the issues list.\n\nAn issues list identifies open, unresolved, or contentious points in the current draft that require negotiation or resolution before signing. Focus on points that are material to the represented party's commercial and legal position.\n\nOnce the represented party is clear, use the generate_docx tool to produce the issues list as a downloadable Word document. Do not display the issues list inline — generate the .docx file and provide the download link.\n\nThe Word document must contain exactly one result table. List each open issue in order from highest to lowest priority and add a final row called **Overall Negotiation Position**. The result table must have exactly these columns:\n\n- Issue\n- Current Position\n- Proposed Change\n\nUse these priority ratings at the start of the **Current Position** column: Critical means a blocking issue that must be resolved before signing; High means a material point requiring negotiation; Medium means a negotiation concern but manageable; Low means a minor point for consideration only. Include relevant clause references in the **Current Position** column. The **Proposed Change** column must state the specific amendment or position the represented party should seek, drafted from that party's perspective. Keep the response concise and focused on actionable negotiation points.\n\n## Result Table Format\n\nThe Word document must use this result table structure. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Current Position | Proposed Change |\n| --- | --- | --- |\n| Liability Cap | High — Clause [x] contains no monetary cap on the represented party's total liability under the agreement. | Add a liability cap equal to [amount] or a multiple of fees paid, with carve-outs limited to fraud and wilful misconduct. |\n| Governing Law | Medium — Clause [x] specifies [jurisdiction] law, which is inconvenient for the represented party's operations and legal advisers. | Change governing law to [preferred jurisdiction] and amend the dispute resolution clause accordingly. |\n\nUse the table field in the document section object, not prose content, for the issues list rows.\n\nBefore finalizing, double-check that the table is formatted correctly: it must have exactly the three columns above in the same order, headers must match exactly (Issue, Current Position, Proposed Change), every row must have the same number of cells as the headers, issues must be ordered from highest to lowest priority, and no cells should contain stray markdown, newlines, or placeholder text unless the source document requires a placeholder.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-ediscovery-tabular-review", + "metadata": { + "title": "E-Discovery Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Litigation", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# E-Discovery Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Date", + "format": "date", + "prompt": "What is the date of this document? For emails or correspondence, use the date sent. For other documents, use the date of creation, signature, or the most prominent date shown." + }, + { + "index": 1, + "name": "Type of Document", + "format": "text", + "prompt": "What type of document is this? (e.g. email, memorandum, letter, contract, report, meeting minutes, text message, invoice, presentation). Be specific." + }, + { + "index": 2, + "name": "Sender", + "format": "text", + "prompt": "Who is the sender or author of this document? State their full name, title, and organisation where identifiable." + }, + { + "index": 3, + "name": "Recipient(s)", + "format": "bulleted_list", + "prompt": "Who are the recipients of this document? List all To, CC, and BCC recipients where identifiable. State their full name, title, and organisation for each. Note whether they appear in To, CC, or BCC fields." + }, + { + "index": 4, + "name": "Summary", + "format": "text", + "prompt": "Provide a concise factual summary of the content of this document in 2–4 sentences. Focus on the key subject matter, any decisions made, actions requested, or information conveyed. Do not include legal conclusions." + }, + { + "index": 5, + "name": "Persons Mentioned", + "format": "bulleted_list", + "prompt": "List all individuals mentioned in this document (other than the sender and recipients already identified). For each person, state their name and, if discernible, their role or organisation." + }, + { + "index": 6, + "name": "Privileged?", + "format": "yes_no", + "prompt": "Does this document appear to be legally privileged? Answer Yes if it appears to be a communication between a lawyer and client made for the dominant purpose of obtaining or giving legal advice, or created for the dominant purpose of litigation. Answer No otherwise. If uncertain, note the basis for uncertainty." + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-employment-agreement-review", + "metadata": { + "title": "Employment Agreement Review", + "description": "Review the uploaded employment agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Employment", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Employment Agreement Review\n\n## Instructions\n\nReview the uploaded employment agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High - restrictive covenant scope is excessive\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to another party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Restrictive Covenants | High - Clause [x] imposes a long non-compete with broad geographic scope. The general covenant position and the employee-specific checklist point indicate enforceability and mobility risk for the employee. | For the employee, reduce the duration, territory, and restricted activities to what is necessary for legitimate business protection. |\n| Drafting Consistency | Medium - Clauses [x] and [y] use inconsistent defined terms or party names. The general drafting point indicates ambiguity that may affect the represented party's rights or obligations. | For the represented party, align the defined terms, party names, numbering, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Employer or Employee).\n\n| Issue | General | Employer | Employee |\n| --- | --- | --- | --- |\n| Parties and Role | Identify employer and employee with correct legal names. Confirm job title, reporting line, work location, and employment type. Flag incorrect entities, unclear role scope, or terms creating unintended employment relationships. | | |\n| Start Date and Term | State commencement date, probation period, fixed term, renewal mechanics, and conditions precedent. | Flag unclear commencement mechanics or terms that limit employer flexibility during the probation period. | Flag long probation periods, unclear renewal mechanics, or employer-only extension rights. |\n| Duties | Summarize duties, working hours, exclusivity, travel requirements, and flexibility clauses. | Flag overly narrow duties that restrict operational flexibility or the employer's ability to change the role. | Flag open-ended duties, excessive hours, broad travel requirements, or unilateral employer change rights. |\n| Compensation | Identify salary, payment frequency, reviews, bonus, commission, allowances, and discretion. | Flag discretionary compensation arrangements that may create enforceable entitlement expectations. | Flag fully discretionary compensation, unclear bonus criteria, or missing payment timing. |\n| Benefits | Summarize pension, insurance, leave, expenses, equity incentives, and employer discretion. | Flag benefit commitments that may be difficult to modify or withdraw without breach. | Flag benefits that can be withdrawn without notice or that conflict with the offer letter. |\n| Holiday and Leave | State annual leave, sickness absence, statutory leave, notice requirements, and carry-over rules. | Flag carry-over obligations or leave accrual that creates significant financial liability. | Flag entitlements below statutory minimums, unclear carry-over rules, or inadequate sick pay. |\n| Policies and Handbook | Identify incorporated policies, contractual status, and amendment rights. | Flag policies incorporated as contractual that cannot be changed unilaterally without employee consent. | Flag policies incorporated as contractual while the employer retains unilateral amendment rights. |\n| Confidentiality | Summarize confidentiality obligations during and after employment. | Flag confidentiality obligations too narrow to protect business-critical information. | Flag overbroad confidential information definitions or indefinite restrictions beyond legitimate business interests. |\n| Intellectual Property | Identify ownership, assignment, inventions, works, moral rights waivers, and assistance obligations. | Flag gaps in IP assignment covering inventions or works made during employment or using company resources. | Flag assignment of unrelated personal inventions, works outside employment scope, or unpaid post-employment assistance obligations. |\n| Data Protection and Monitoring | Summarize monitoring, privacy, processing, and consent provisions. | Flag monitoring arrangements that may expose the employer to data protection liability. | Flag intrusive monitoring, blanket consent provisions, or missing privacy notice references. |\n| Conflicts and Outside Activities | Identify restrictions on outside work, directorships, investments, conflicts, and disclosure duties. | Flag insufficient restrictions on outside activities or conflicts that could harm business interests. | Flag broad bans on passive investments, restrictions on unrelated outside work, or disclosure obligations for non-conflicting activities. |\n| Termination | State notice periods, payment in lieu, garden leave, summary dismissal, severance, and survival terms. | Flag notice periods or severance obligations that unduly limit employer termination flexibility. | Flag one-sided notice rights, broad summary dismissal triggers, or unclear payment in lieu treatment. |\n| Restrictive Covenants | Summarize non-compete, non-solicit, non-dealing, non-poach, and confidentiality covenants. | Flag covenants too narrow in scope, too short in duration, or likely to be unenforceable. | Flag excessive duration, broad geographic scope, wide covered customers, or covenants that may be unenforceable restraints. |\n| Return of Property | Identify obligations to return devices, documents, data, confidential information, and property. | Flag unclear scope of return obligations or no mechanism to enforce retrieval of business property. | Flag no carve-out for personal materials or obligations that require returning statutory record retention copies. |\n| Governing Law and Dispute Resolution | State governing law, courts, tribunal forum, arbitration, and mandatory procedures. Flag unfamiliar law, unclear mandatory procedures, or arbitration clauses that may limit statutory rights. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-employment-agreement-tabular-review", + "metadata": { + "title": "Employment Agreement Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Employment", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Employment Agreement Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Employer", + "format": "text", + "prompt": "Who is the employer under this agreement? State the full legal name and jurisdiction of incorporation or establishment." + }, + { + "index": 1, + "name": "Employee", + "format": "text", + "prompt": "Who is the employee under this agreement? State their full name and, if provided, their address or location." + }, + { + "index": 2, + "name": "Date", + "format": "date", + "prompt": "What is the date of this employment agreement? If a commencement date or start date differs from the signing date, state both." + }, + { + "index": 3, + "name": "Title", + "format": "text", + "prompt": "What is the employee's job title or position as stated in this agreement? If a reporting line is specified, include it." + }, + { + "index": 4, + "name": "Compensation", + "format": "text", + "prompt": "What is the employee's compensation under this agreement? State the base salary or wage, the currency, and the payment frequency (e.g. monthly, bi-weekly). Include any guaranteed bonus, commission, or other fixed remuneration elements." + }, + { + "index": 5, + "name": "Full Time / Part Time", + "format": "tag", + "tags": [ + "Full Time", + "Part Time" + ], + "prompt": "Is this a full-time or part-time position? If part-time, state the number of days or hours per week where specified." + }, + { + "index": 6, + "name": "Independent Contractor?", + "format": "yes_no", + "prompt": "Does the agreement characterise the worker as an independent contractor rather than an employee? Answer Yes if the agreement uses contractor, consultant, or self-employed language. Note any provisions that address the nature of the relationship." + }, + { + "index": 7, + "name": "Benefits", + "format": "bulleted_list", + "prompt": "What benefits are the employee entitled to under this agreement? List each benefit (e.g. health insurance, pension/retirement contributions, life assurance, car allowance, share options, expense reimbursement). Note any eligibility conditions or limits." + }, + { + "index": 8, + "name": "Notice Period (Employer to Employee)", + "format": "text", + "prompt": "What notice must the employer give to terminate the employee's employment (other than for cause)? State the notice period and any provisions for payment in lieu of notice." + }, + { + "index": 9, + "name": "Notice Period (Employee to Employer)", + "format": "text", + "prompt": "What notice must the employee give to resign? State the notice period and any provisions for payment in lieu of notice or garden leave." + }, + { + "index": 10, + "name": "Overtime", + "format": "text", + "prompt": "What provisions apply to overtime? Is the employee eligible for overtime pay, and if so at what rate? Or does the agreement state that the salary is inclusive of any overtime? Note any opt-out of statutory working time limits." + }, + { + "index": 11, + "name": "Working Hours", + "format": "text", + "prompt": "What working hours are specified in this agreement? State the normal hours of work, any flexibility provisions, and whether the employee is expected to work additional hours as required." + }, + { + "index": 12, + "name": "Variation", + "format": "text", + "prompt": "What provisions govern variation of the terms of this agreement? Can the employer unilaterally vary terms, or is the employee's consent required? Note any specific terms that are stated to be variable without consent." + }, + { + "index": 13, + "name": "Intellectual Property Assignment", + "format": "text", + "prompt": "What intellectual property assignment provisions are included? Does the employee assign to the employer all IP created in the course of employment? Are there any carve-outs for pre-existing IP or inventions created outside working hours? Note any moral rights waiver." + }, + { + "index": 14, + "name": "Grounds for Termination", + "format": "bulleted_list", + "prompt": "What grounds for summary dismissal or termination for cause are set out in the agreement? List each ground (e.g. gross misconduct, breach of confidentiality, insolvency, criminal conviction). Note whether summary dismissal is without notice or payment in lieu." + }, + { + "index": 15, + "name": "Annual Leave Entitlement", + "format": "text", + "prompt": "What is the employee's annual leave entitlement? State the number of days (or weeks) per year, whether this is inclusive of or in addition to public holidays, and any provisions for accrual, carry-over, or payment of untaken leave on termination." + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-guarantee-agreement-review", + "metadata": { + "title": "Guarantee Agreement Review", + "description": "Review the uploaded guarantee, guaranty, or guarantee-and-indemnity agreement and produce a comprehensive table-based legal review from the perspective of the party represented...", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Finance", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Guarantee Agreement Review\n\n## Instructions\n\nReview the uploaded guarantee, guaranty, or guarantee-and-indemnity agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High - guarantee is uncapped\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to another party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Cap and Limits | High - Clause [x] does not include a monetary cap or time limit. The general exposure point and the guarantor-specific checklist point indicate uncapped liability risk for the guarantor. | For the guarantor, add a monetary cap, currency limit, time limit, and exclusions for obligations outside the agreed transaction. |\n| Drafting Consistency | Medium - Clauses [x] and [y] use inconsistent defined terms or party names. The general drafting point indicates ambiguity that may affect the represented party's rights or obligations. | For the represented party, align the defined terms, party names, numbering, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Guarantor or Guarantee Holder).\n\n| Issue | General | Guarantor | Guarantee Holder |\n| --- | --- | --- | --- |\n| Parties | Identify all parties with full legal names and roles, including guarantor, principal obligor, agent, and security trustee. Flag incorrect entities, missing capacity details, or unclear party or agent structure. | | |\n| Guaranteed Obligations | Summarize payment, performance, indemnity, costs, interest, fees, future advances, and all-monies coverage. | Flag broad all-monies coverage, obligations beyond the intended transaction, or future advances not agreed at outset. | Flag gaps in guaranteed obligations or exclusions of fees, interest, or enforcement costs. |\n| Nature of Liability | State whether liability is primary, secondary, joint and several, continuing, independent, on-demand, or principal debtor liability. | Flag primary, on-demand, or principal debtor liability where only secondary guarantee risk was intended. | Flag secondary-only liability or absence of principal debtor language that weakens enforcement. |\n| Guarantee vs Indemnity | Identify any separate indemnity or principal debtor language and explain practical effect. | Flag separate indemnity language that expands exposure beyond the underlying obligation or bypasses guarantor defences. | Flag absence of indemnity or principal debtor language that could allow defences to defeat enforcement. |\n| Cap and Limits | State monetary cap, currency, interest cap, time limit, excluded obligations, and other limits. | Flag uncapped exposure, unclear currency, or no temporal limit on guaranteed obligations. | Flag caps too low to cover the full facility, or caps that exclude interest, fees, and enforcement costs. |\n| Continuing Security | Summarize future, contingent, amended, refinanced, reinstated, or continuing obligations. | Flag coverage of unknown future obligations or amendments to the underlying facility without guarantor consent. | Flag gaps in continuing security coverage or restrictions on amendments that limit lender flexibility. |\n| Demand Mechanics | Explain demand triggers, notice, payment timing, evidence, and conclusiveness. | Flag immediate payment without adequate evidence, conclusive certificates, or no notice period before demand. | Flag demand mechanics too cumbersome or conditions that allow the guarantor to delay payment. |\n| Defences and Waivers | Identify waivers of suretyship defences, diligence, presentment, set-off, marshalling, subrogation, contribution, and notice. | Flag broad waivers of fundamental defences, excessive restriction of set-off, or waiver of subrogation rights. | Flag incomplete waiver of suretyship defences that could allow the guarantor to avoid payment. |\n| Variations and Releases | Summarize effect of amendments, waivers, releases, insolvency events, or underlying document changes. | Flag guarantee surviving material changes to the underlying facility without guarantor consent. | Flag restrictions on amendments or waivers requiring guarantor consent that limit lender flexibility. |\n| Reinstatement | Identify clawback, preference, invalid payment, or reinstatement provisions. | Flag indefinite reinstatement obligations or reinstatement beyond reasonable insolvency clawback risk. | Flag absence of reinstatement provisions that could leave the guarantee holder exposed after a clawback. |\n| Subordination | Summarize restrictions on guarantor claims, subrogation, contribution, or proof in insolvency. | Flag indefinite blockage of guarantor recovery or subrogation rights after the guarantee holder is paid in full. | Flag absence of subordination provisions that could allow the guarantor to compete with the guarantee holder in insolvency. |\n| Representations and Covenants | Identify capacity, authority, solvency, consents, sanctions, and compliance covenants. | Flag repeating representations, broad compliance obligations, or solvency statements that may be difficult to give. | Flag weak representations or covenants that do not adequately confirm guarantor capacity and authority. |\n| Termination and Release | State how the guarantee may be terminated, released, discharged, or reduced. | Flag no release mechanism after repayment or facility termination, or unclear discharge conditions. | Flag release mechanics too easy to trigger or that do not require full repayment and discharge. |\n| Costs, Expenses, and Taxes | Summarize reimbursement, gross-up, tax indemnities, and enforcement costs. | Flag unlimited costs, gross-up for avoidable taxes, or broad indemnities beyond reasonable enforcement costs. | Flag cost recovery gaps that leave enforcement costs unrecoverable from the guarantor. |\n| Assignment and Transfer | Identify whether the guarantee holder may assign or transfer benefit and whether guarantor consent is required. | Flag free transfer of benefit to unknown creditors, distressed debt purchasers, or competitors without consent. | Flag consent mechanics that give the guarantor excessive control over assignment of the guarantee benefit. |\n| Governing Law and Dispute Resolution | State governing law, jurisdiction, arbitration, service of process, and immunity waiver provisions. Flag unfamiliar law, inconvenient forum, broad immunity waiver, unclear service mechanics, or inadequate interim relief provisions. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-lpa-tabular-review", + "metadata": { + "title": "Limited Partnership Agreement Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Private Equity", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Limited Partnership Agreement Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "General Partner", + "format": "text", + "prompt": "Identify the General Partner(s) of the fund. State the full legal name, jurisdiction of establishment, and any affiliated management entity (e.g. the fund manager or investment adviser) named in the agreement." + }, + { + "index": 1, + "name": "Fund Name & Jurisdiction", + "format": "text", + "prompt": "What is the full name of the fund and in which jurisdiction is the limited partnership established or registered?" + }, + { + "index": 2, + "name": "Total Committed Capital", + "format": "monetary_amount", + "prompt": "What is the total committed capital of the fund? State the target size, any hard cap, the currency, and the closing date or dates if specified." + }, + { + "index": 3, + "name": "Capital Calls & Drawdowns", + "format": "text", + "prompt": "How and when may the GP call capital from LPs? State the notice period for capital calls, the mechanics for issuing a call notice, any limit on the frequency or size of calls, and whether undrawn commitments can be recalled after repayment." + }, + { + "index": 4, + "name": "Penalties for Failure to Fund", + "format": "text", + "prompt": "What are the consequences if an LP fails to fund a capital call? Describe any penalties (e.g. interest on the shortfall, dilution of interest, forced transfer at a discount, loss of voting or distribution rights, exclusion from future investments). Are there any cure periods before penalties apply?" + }, + { + "index": 5, + "name": "Investment Scope & Restrictions", + "format": "text", + "prompt": "What is the fund's stated investment strategy, scope, and any restrictions? Include permitted sectors, geographies, investment stages, instrument types, and any concentration limits (e.g. maximum % of committed capital per single investment). Note how much discretion the GP has to deviate from the stated strategy." + }, + { + "index": 6, + "name": "Fund Term", + "format": "text", + "prompt": "What is the term of the fund? State the initial term (e.g. 10 years from final closing), any permitted extension periods (e.g. 2 × 1-year extensions), who has the right to approve extensions (GP alone or with LP/LPAC consent), and any early termination mechanics." + }, + { + "index": 7, + "name": "Management Fee", + "format": "text", + "prompt": "What management fee is payable to the GP or manager? State the fee rate, the basis on which it is calculated (e.g. committed capital during the investment period, then invested or net asset value thereafter), any step-downs over the fund life, and the payment frequency." + }, + { + "index": 8, + "name": "Carried Interest", + "format": "text", + "prompt": "What carried interest (carry) is payable to the GP? State the carry percentage, the structure (European/fund-level waterfall vs American/deal-by-deal), and identify each step of the distribution waterfall in sequence (e.g. return of capital, preferred return, GP catch-up, then profit split)." + }, + { + "index": 9, + "name": "Preferred Return (Hurdle Rate)", + "format": "percentage", + "prompt": "Is there a preferred return or hurdle rate that LPs must receive before the GP earns carry? State the rate, whether it is compounded (and on what basis), and how it is calculated (e.g. on invested capital, on contributed capital). If there is no preferred return, state this explicitly." + }, + { + "index": 10, + "name": "GP Catch-Up", + "format": "text", + "prompt": "Is there a GP catch-up mechanism after the preferred return is met? If so, describe how it operates: what percentage of distributions go to the GP during the catch-up, and what economic result the catch-up is designed to achieve (e.g. the GP receives 20% of all profits to date)." + }, + { + "index": 11, + "name": "Clawback", + "format": "text", + "prompt": "Is there a clawback obligation on the GP if it receives excess carry? State whether the clawback is calculated at fund level or individual partner level, when it is triggered, any cap or limit on the clawback obligation, and whether there is any escrow or security arrangement to support the GP's clawback obligation." + }, + { + "index": 12, + "name": "Fees & Expenses (Beyond Management Fee)", + "format": "bulleted_list", + "prompt": "What fees and expenses are charged to the fund or LPs beyond the management fee? List each category (e.g. transaction fees, monitoring fees, broken deal costs, formation expenses, legal fees, fund administration costs, organisational expenses). For each, state who bears the cost and whether any amounts are offset against the management fee." + }, + { + "index": 13, + "name": "Distributions", + "format": "text", + "prompt": "How and when are distributions made to LPs? Describe the timing of distributions (e.g. upon realisation of investments or at the GP's discretion), whether the GP can reinvest proceeds within the investment period, and whether distributions may be made in-kind (i.e. as securities rather than cash)." + }, + { + "index": 14, + "name": "Key Person Clause", + "format": "text", + "prompt": "Is there a key person clause? Identify the designated key persons. What triggers the key person event (e.g. departure, incapacity, reduced time commitment below a threshold)? What are the consequences (e.g. suspension of the investment period)? Do LPs have any right to terminate or vote on continuation following a key person event?" + }, + { + "index": 15, + "name": "Removal of the GP", + "format": "text", + "prompt": "Under what circumstances can the GP be removed? Distinguish between removal for cause (e.g. fraud, gross negligence, wilful misconduct — state the LP voting threshold required) and removal without cause (state the LP voting threshold and any associated consequences such as carried interest treatment on removal)." + }, + { + "index": 16, + "name": "Advisory Committee (LPAC)", + "format": "text", + "prompt": "Is there an LP Advisory Committee (LPAC) or similar governance body? If so, describe its composition, how members are selected, its key powers and responsibilities (e.g. approving conflicts of interest, valuations, extensions, related-party transactions), and whether its approval is binding or merely advisory." + }, + { + "index": 17, + "name": "Transfer Restrictions", + "format": "text", + "prompt": "What restrictions apply to an LP transferring or assigning its interest in the fund? Is GP consent required? Are there any permitted transfer exceptions (e.g. to affiliates)? Are secondary market sales permitted and, if so, subject to what conditions or rights of first refusal?" + }, + { + "index": 18, + "name": "Conflicts of Interest", + "format": "text", + "prompt": "How does the agreement address conflicts of interest? Describe the deal allocation policy across funds, any co-investment rights granted to LPs, restrictions on related-party transactions, and the role of the LPAC in reviewing or approving conflicts. Note any specific conflict scenarios expressly contemplated." + }, + { + "index": 19, + "name": "Governing Law", + "format": "text", + "prompt": "What governing law applies to this agreement and which courts or arbitral tribunals have jurisdiction over disputes?" + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-nda-review", + "metadata": { + "title": "NDA Review", + "description": "Review the uploaded non-disclosure agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# NDA Review\n\n## Instructions\n\nReview the uploaded non-disclosure agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High - confidential information definition is too narrow\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to another party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Definition of Confidential Information | Medium - Clause [x] covers broad categories of information but does not clearly address oral disclosures. The general definition point and the disclosing-party checklist point indicate a protection gap. | For the disclosing party, add oral and visual disclosures and require written confirmation within a defined period. |\n| Drafting Consistency | Medium - Clauses [x] and [y] use inconsistent defined terms or party names. The general drafting point indicates ambiguity that may affect the represented party's rights or obligations. | For the represented party, align the defined terms, party names, numbering, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Disclosing Party or Receiving Party).\n\n| Issue | General | Disclosing Party | Receiving Party |\n| --- | --- | --- | --- |\n| Parties | Identify each party and their role. Confirm whether the NDA is mutual or one-way and that obligations are correctly allocated. Flag missing affiliates, mismatched roles, or obligations inconsistent with the agreed structure. | | |\n| Purpose | Summarize the permitted purpose for which confidential information may be used. | Flag overly broad purpose language that permits use beyond the intended transaction. | Flag unclear or narrow purpose that prevents legitimate use of information in connection with the transaction. |\n| Definition of Confidential Information | Explain what information is protected, including oral, visual, derived, or pre-existing information. | Flag definitions too narrow to protect all disclosed information, or no protection for oral or unmarked information. | Flag definitions overbroad, capturing publicly available information or information not actually sensitive. |\n| Exclusions | Identify standard exclusions such as public domain, independently developed, already known, or third-party received information. | Flag missing standard exclusions that would allow the receiving party to escape obligations too easily. | Flag missing standard exclusions (public domain, independently developed, already known) or exclusions that are too difficult to prove. |\n| Disclosure Obligations | Summarize confidentiality obligations, standard of care, and use or disclosure restrictions. | Flag standards of care too weak to adequately protect the information. | Flag strict liability, vague standards, or obligations higher than own-information standards that expose the receiving party disproportionately. |\n| Permitted Recipients | Identify who may receive confidential information, including affiliates, representatives, advisers, financing sources, or investors. | Flag recipient categories too broad, or no requirement for recipients to be bound by equivalent obligations. | Flag missing adviser, affiliate, or financing source access rights needed to evaluate the transaction. |\n| Recipient Liability | State whether the receiving party is responsible for breaches by permitted recipients. | Flag absence of receiving party responsibility for breaches by permitted recipients. | Flag uncapped liability for actions of recipients outside the party's direct control. |\n| Compelled Disclosure | Summarize disclosure required by law, regulation, court order, stock exchange, or governmental authority. | Flag no notice right, no cooperation obligation, or overly broad compelled disclosure permissions. | Flag overly burdensome notice requirements or obligations to resist disclosure beyond what is practicable. |\n| Term | State the agreement term and the duration of confidentiality obligations. | Flag obligations that expire too soon or unclear continuation of obligations after agreement end. | Flag indefinite obligations or unusually long terms for non-trade-secret information. |\n| Return or Destruction | Summarize return, destruction, retention, and archival copy obligations. | Flag no return obligation or inadequate destruction mechanics. | Flag no carve-out for archival, compliance, backup, or legal retention copies. |\n| Residual Knowledge | Identify whether unaided memory or residual knowledge may be used. | Flag broad residual knowledge rights that could undermine confidentiality protections. | Flag absence of a residual knowledge carve-out that prevents legitimate use of unaided memory. |\n| Non-Solicit / Standstill | Identify non-solicitation, non-circumvention, standstill, exclusivity, or similar restrictions. | Flag absence of non-solicitation or standstill protections where commercially expected. | Flag hidden restrictive covenants, long durations, broad covered persons, or restrictions unrelated to confidentiality. |\n| No Warranty / No Obligation | Summarize disclaimers about accuracy, completeness, warranties, or obligation to proceed. | Flag missing disclaimers that could create liability for the accuracy or completeness of disclosed information. | Flag disclaimers that conflict with fraud or intentional misrepresentation, or that expressly exclude reliance. |\n| Remedies | Identify injunctive relief, equitable remedies, indemnities, liquidated damages, or enforcement provisions. | Flag absence of injunctive relief or inadequate remedies for breach. | Flag automatic injunction language, broad indemnities, or liquidated damages without adequate safeguards. |\n| Assignment | Summarize restrictions on assignment or transfer. | Flag free assignment rights that could transfer obligations to unknown parties. | Flag restrictions that block legitimate affiliate transfers needed for the deal process. |\n| Governing Law and Dispute Resolution | State governing law, forum, arbitration provisions, and submission to jurisdiction. Flag unfamiliar law, asymmetric process, inconvenient forum, or missing service provisions. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-nda-tabular-review", + "metadata": { + "title": "NDA Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# NDA Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Direction", + "format": "tag", + "tags": [ + "Mutual", + "Unilateral" + ], + "prompt": "Is this NDA mutual (both parties owe confidentiality obligations to each other) or unilateral (only one party owes confidentiality obligations)? Identify the direction and name the disclosing and receiving party or parties." + }, + { + "index": 1, + "name": "Definition of Confidential Information", + "format": "text", + "prompt": "How is 'Confidential Information' defined in this agreement? Is it broadly or narrowly drafted? Does it require information to be marked as confidential, or is all information shared in connection with the purpose automatically covered? Note any express inclusions or exclusions." + }, + { + "index": 2, + "name": "Obligations of Receiving Party", + "format": "bulleted_list", + "prompt": "What are the key obligations of the receiving party in respect of the confidential information? List each obligation (e.g. keep confidential, not disclose to third parties, use only for the permitted purpose, apply a specific standard of care, restrict access to need-to-know personnel)." + }, + { + "index": 3, + "name": "Standard Carveouts Present?", + "format": "yes_no", + "prompt": "Does the agreement include the standard carveouts to confidentiality obligations? Answer Yes if the agreement excludes information that: (a) is or becomes publicly available without breach; (b) was already known to the receiving party; (c) is independently developed; and (d) is received from a third party without restriction. Note any carveouts that are missing or are drafted differently from the standard formulation." + }, + { + "index": 4, + "name": "Permitted Disclosures", + "format": "bulleted_list", + "prompt": "To whom may the receiving party disclose confidential information? List each category of permitted recipient (e.g. employees, professional advisers, affiliates, financing parties, regulatory authorities). Note whether onward disclosure requires the recipient to be bound by equivalent obligations." + }, + { + "index": 5, + "name": "Term and Duration", + "format": "text", + "prompt": "What is the term of this NDA and how long do the confidentiality obligations last? State the initial term of the agreement and the duration of the confidentiality obligations (noting whether they survive termination and for how long)." + }, + { + "index": 6, + "name": "Return and Destruction", + "format": "text", + "prompt": "What obligations apply on expiry or termination regarding return or destruction of confidential information? Is there a choice between return and destruction? Must destruction be certified? Are there any retention exceptions (e.g. for regulatory purposes, IT backup systems)?" + }, + { + "index": 7, + "name": "Remedies", + "format": "text", + "prompt": "What remedies are available for breach of the confidentiality obligations? Does the agreement acknowledge that damages may be inadequate and that injunctive relief or specific performance is available? Are there any agreed liquidated damages or indemnities for breach?" + }, + { + "index": 8, + "name": "Governing Law and Jurisdiction", + "format": "text", + "prompt": "What governing law applies to this agreement and which courts have jurisdiction? State the chosen law, the forum, and whether jurisdiction is exclusive or non-exclusive." + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-proofread", + "metadata": { + "title": "Proofread", + "description": "Review the uploaded document for drafting quality, internal consistency, and mechanical errors.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Proofread\n\n## Instructions\n\nReview the uploaded document for drafting quality, internal consistency, and mechanical errors. Focus on issues that a lawyer or reviewer should correct before the document is circulated, signed, or filed.\n\nProduce a concise Markdown table with exactly these columns:\n\n- Severity\n- Category\n- Location\n- Issue\n- Recommended Fix\n\nUse these severity ratings:\n\n- Critical: an inconsistency or drafting error that may materially change rights, obligations, parties, timing, or enforceability.\n- High: an error likely to create ambiguity, negotiation friction, or implementation risk.\n- Medium: a proofreading or consistency issue that should be fixed but is unlikely to change the main legal effect.\n- Low: a minor typo, grammar, formatting, punctuation, or style issue.\n\nReview the document for these categories:\n\n| Category | What to Check |\n| --- | --- |\n| Definitions | Check that defined terms are used consistently, all capitalized defined terms are defined, definitions are not duplicated or conflicting, definitions are not circular, unused definitions are identified, and defined terms match the operative provisions. |\n| Cross-References | Check that clause, section, schedule, exhibit, annex, and document references exist, point to the correct place, use the correct numbering, and remain accurate after any apparent drafting changes. |\n| Internal Consistency | Check for terms, sections, dates, parties, amounts, thresholds, notice periods, conditions, remedies, or obligations that conflict with each other or create inconsistent outcomes. |\n| Parties and Entity Names | Check that party names, entity suffixes, registration details, capacities, addresses, signatory names, and role labels are consistent throughout the document. |\n| Numbers, Dates, and Calculations | Check monetary amounts, percentages, dates, deadlines, time periods, notice periods, interest rates, formulas, schedules, and words-versus-figures consistency. |\n| Grammar and Typos | Check spelling, grammar, punctuation, missing words, duplicate words, repeated phrases, tense, subject-verb agreement, and obvious typographical errors. |\n| Numbering | Check clause numbering, section numbering, sub-clause hierarchy, schedule numbering, exhibit numbering, table numbering, skipped numbers, duplicate numbers, and numbering sequences. If the document has a table of contents, check that clause numbers, headings, and hierarchy are consistent with the table of contents. |\n| Formatting | Check headings, list formatting, indentation, spacing, fonts, emphasis, table formatting, schedule formatting, and inconsistent styles that may affect readability or references. |\n\nFor each issue, include the best available clause, section, page, schedule, or paragraph reference in the **Location** column. If the location is unclear, describe where the issue appears as specifically as possible.\n\nIn the **Issue** column, explain the problem and why it matters. In the **Recommended Fix** column, provide a specific correction or drafting action. Where useful, include replacement wording, but keep it concise. Do not rewrite the whole document. If no material issues are found, provide a short table row stating that no material proofreading issues were identified and note any review limitations.\n\nBefore finalizing, double-check that definitions, cross-references, numbering, formatting, and internal inconsistencies have each been considered separately. If a table of contents is present, verify that the clause numbers and headings in the document match the table of contents. Keep the response focused on actionable corrections.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-sha-summary", + "metadata": { + "title": "Shareholder Agreement Review", + "description": "Review the uploaded shareholder agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Corporate", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Shareholder Agreement Review\n\n## Instructions\n\nReview the uploaded shareholder agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — drag threshold is too low\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Drag-Along Rights | High — Clause [x] sets the drag threshold at [x]% and does not include a minimum price protection. The minority-specific checklist point indicates the minority can be forced to sell at an unfavorable price. | For the minority, raise the drag threshold, add a minimum price equal to fair market value, and limit warranties to proceeds received. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the share classes. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, share class definitions, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Majority or Minority).\n\n| Issue | General | Majority | Minority |\n| --- | --- | --- | --- |\n| Parties and Shareholdings | Identify each shareholder, role, share class, percentage holding, and fully diluted position. Flag inconsistent cap table information, missing parties, or unclear beneficial ownership. | | |\n| Share Classes and Rights | Summarize voting rights, dividend rights, liquidation preferences, conversion rights, redemption rights, and class consents. | Flag class consents or voting requirements that effectively give minority approval rights over ordinary decisions. | Flag hidden preference rights, disproportionate majority voting rights, or unfavorable class economics. |\n| Board Composition and Governance | Identify board size, appointment rights, observer rights, quorum, chair, and casting vote. | Flag quorum or deadlock provisions giving minority an effective veto over governance. | Flag entrenched majority appointment rights, casting vote held by a majority nominee, or no observer rights for minority. |\n| Reserved Matters | List matters requiring special majority, unanimity, investor consent, or board consent. | Flag reserved matters requiring minority consent that constrain ordinary business decisions. | Flag absence of meaningful minority veto rights or low-dollar thresholds allowing majority to structure around them. |\n| Pre-emption on New Shares | Summarize pre-emption rights, offer process, timing, exclusions, and carve-outs. | Flag mechanics slowing fundraising or requiring minority consent to issue new shares. | Flag broad carve-outs, short exercise periods, or mechanics permitting dilution without proper notice. |\n| Transfer Restrictions | Summarize lock-ups, prohibited transfers, permitted transfers, and consent requirements. | Flag restrictions preventing majority exit or group reorganisation without minority consent. | Flag absence of lock-up on majority transfers or mechanics allowing majority to exit leaving minority trapped. |\n| Right of First Refusal / Pre-emption on Transfer | Identify trigger, process, pricing, matching rights, and exceptions. | Flag long ROFR processes or pricing mechanics delaying majority exit. | Flag unclear matching rights, long processes excluding minority participation, or exceptions removing minority protections. |\n| Drag-Along Rights | Identify drag threshold, sale conditions, minority protections, and power of attorney. | Flag high drag threshold or conditions preventing majority from executing a sale. | Flag low drag threshold, no minimum price protection, forced warranties beyond proceeds received, or broad power of attorney. |\n| Tag-Along Rights | Identify triggering transfers, eligible holders, and sale terms. | Flag broad tag triggers complicating majority exit. | Flag missing or narrow tag rights, or mechanics allowing majority to exclude minority from exit proceeds. |\n| Anti-Dilution Protections | Identify anti-dilution mechanics, carve-outs, and adjustment triggers. | Flag mechanics punishing founders or majority holders on down-rounds. | Flag absence of anti-dilution protection, full ratchet terms, or unclear carve-outs from adjustment. |\n| Dividend Policy | Summarize dividend rights, preferential dividends, restrictions, and discretion. | Flag mandatory dividend obligations impairing business cash flow or restricting majority discretion. | Flag unclear preferential dividend rights or majority discretion over dividends without minority consent. |\n| Exit and Liquidity | Identify IPO, trade sale, redemption, put/call rights, timelines, and liquidation preferences. | Flag forced exit timelines or investor put rights constraining majority exit strategy. | Flag unclear waterfall on exit, exit rights not applying equally, or no minority participation in liquidity events. |\n| Deadlock | Summarize deadlock definition, escalation, expert determination, shoot-out, and liquidation mechanics. | Flag deadlock provisions giving minority disproportionate leverage or effective veto. | Flag shoot-out rights triggered at low thresholds or mechanics structurally favoring majority. |\n| Non-Compete and Non-Solicitation | Identify who is bound, restricted activities, geography, duration, and carve-outs. | Flag restraints binding the majority entity or overly broad scope applied to the majority group. | Flag broad non-competes restricting the minority's ability to operate independently after exit. |\n| Information Rights | Identify reporting, inspection, management accounts, and confidentiality obligations. | Flag extensive information rights giving minority commercially sensitive operational visibility. | Flag absence of reporting or inspection rights, or inadequate confidentiality obligations on recipients. |\n| Related Party Transactions | Identify approval requirements, disclosure duties, and arm's-length standards. | Flag overly strict conflict controls restricting legitimate majority-group transactions. | Flag weak conflict controls, broad permitted related-party dealings, or no arm's-length enforcement. |\n| Governing Law and Dispute Resolution | State governing law, forum, arbitration, escalation, and interim relief rights. Flag unclear or ambiguous forum or dispute mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-shareholder-agreement-tabular-review", + "metadata": { + "title": "Shareholder Agreement Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Corporate", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Shareholder Agreement Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Parties", + "format": "bulleted_list", + "prompt": "Identify all parties to this shareholder agreement. For each, state their full legal name, jurisdiction of incorporation or establishment (if stated), and their role (e.g. company, majority shareholder, minority shareholder, investor, founder, management shareholder)." + }, + { + "index": 1, + "name": "Date", + "format": "date", + "prompt": "What is the date of this shareholder agreement?" + }, + { + "index": 2, + "name": "Share Capital & Classes", + "format": "bulleted_list", + "prompt": "What classes of shares are in issue or contemplated by this agreement? For each class, describe the key rights attaching to it including voting rights, dividend rights, liquidation preference (if any), and any conversion or redemption features." + }, + { + "index": 3, + "name": "Shareholdings", + "format": "bulleted_list", + "prompt": "What are the shareholdings of each party as set out or contemplated in this agreement? For each shareholder, state the number of shares held, the class, and the percentage of total share capital (on a fully diluted basis if stated)." + }, + { + "index": 4, + "name": "Board Composition", + "format": "text", + "prompt": "How is the board of directors constituted under this agreement? State the total number of directors, each shareholder's or class of shareholders' right to appoint or nominate directors (and the threshold shareholding required to maintain that right), and any provisions for a chairman or casting vote." + }, + { + "index": 5, + "name": "Reserved Matters", + "format": "bulleted_list", + "prompt": "What are the reserved matters or veto rights set out in this agreement? List each matter that requires shareholder or director approval beyond an ordinary majority (e.g. special majority, unanimity, or the consent of a specific shareholder). Identify the applicable threshold or whose consent is required for each." + }, + { + "index": 6, + "name": "Pre-emption on New Shares", + "format": "text", + "prompt": "What pre-emption rights apply on the issuance of new shares? Describe who holds pre-emption rights, the procedure for offering new shares to existing shareholders, the timeline for acceptance, and any carve-outs or exceptions (e.g. shares issued under an employee option scheme, permitted issuances)." + }, + { + "index": 7, + "name": "Transfer Restrictions", + "format": "text", + "prompt": "What restrictions apply to the transfer of shares? Identify any lock-up periods (and their duration), which transfers are prohibited outright, and which transfers are permitted without consent (e.g. transfers to affiliates or family trusts). Note any board or shareholder approval requirements for transfers." + }, + { + "index": 8, + "name": "Right of First Refusal / Pre-emption on Transfer", + "format": "text", + "prompt": "Is there a right of first refusal or pre-emption right on a proposed transfer of shares? If so, describe who holds the right, the procedure for triggering and exercising it (including notice periods and pricing mechanics), and any exceptions." + }, + { + "index": 9, + "name": "Drag-Along Rights", + "format": "text", + "prompt": "Are there drag-along rights? If so, identify who holds the drag right (e.g. majority shareholders above a specified threshold), the threshold required to trigger a drag, the obligations imposed on dragged shareholders, any conditions on the drag (e.g. minimum price, independent valuation), and any protections for minority shareholders." + }, + { + "index": 10, + "name": "Tag-Along Rights", + "format": "text", + "prompt": "Are there tag-along rights? If so, identify who holds the tag right, the threshold transfer that triggers the tag, the procedure for exercising the tag (including notice periods), the price and terms on which the tagging shareholder may sell, and any exceptions." + }, + { + "index": 11, + "name": "Anti-Dilution Protections", + "format": "text", + "prompt": "Are there any anti-dilution protections for any class of shareholders? If so, describe the type of protection (e.g. full ratchet, weighted average, broad-based or narrow-based), the trigger events, how the adjusted price or entitlement is calculated, and any exceptions (e.g. permitted issuances excluded from the calculation)." + }, + { + "index": 12, + "name": "Dividend Policy", + "format": "text", + "prompt": "What dividend provisions are set out in this agreement? Describe any obligation or policy to pay dividends (e.g. a minimum percentage of distributable profits), any preferential dividend rights attaching to a particular class of shares, and any restrictions on dividend payments (e.g. subject to available profits, board or shareholder approval, lender consent)." + }, + { + "index": 13, + "name": "Exit & Liquidity Provisions", + "format": "text", + "prompt": "What exit or liquidity provisions are included? Describe any agreed exit mechanisms (e.g. trade sale, IPO, drag-along sale), any timelines or milestones by which an exit is targeted, any shareholder rights to initiate or compel an exit process after a specified period, and any preference on exit proceeds attaching to a particular class of shares." + }, + { + "index": 14, + "name": "Deadlock", + "format": "text", + "prompt": "How is deadlock addressed? Describe any deadlock resolution mechanisms (e.g. escalation to senior management, mediation, Russian roulette / shoot-out provisions, put/call options). For each mechanism, state the trigger conditions, the procedure, and the consequences if deadlock is not resolved." + }, + { + "index": 15, + "name": "Non-Compete & Non-Solicitation", + "format": "text", + "prompt": "Are any shareholders subject to non-compete or non-solicitation obligations? If so, identify which shareholders are bound, the scope of the restriction (activities and geography), and the duration (during the term of the agreement and/or for a period after a shareholder ceases to hold shares). Note any carve-outs." + }, + { + "index": 16, + "name": "Confidentiality", + "format": "text", + "prompt": "What confidentiality obligations are imposed on the shareholders? State the scope of confidential information covered, the permitted disclosures (e.g. to professional advisers, affiliates, lenders), and the duration of the obligation. Note whether the obligation survives termination of the agreement." + }, + { + "index": 17, + "name": "Warranties", + "format": "text", + "prompt": "What warranties are given by the shareholders under this agreement? Identify who gives warranties, the subject matter (e.g. title to shares, capacity, no encumbrances, no conflicts), any limitations on warranty claims (e.g. time limits, caps, knowledge qualifications), and any indemnities given alongside the warranties." + }, + { + "index": 18, + "name": "Governing Law", + "format": "text", + "prompt": "What governing law applies to this agreement? State the jurisdiction and any specific legal system referenced." + }, + { + "index": 19, + "name": "Dispute Resolution", + "format": "text", + "prompt": "How are disputes resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen forum or seat, any mandatory escalation steps, and whether jurisdiction is exclusive." + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-spa-tabular-review", + "metadata": { + "title": "SPA Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "Corporate", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# SPA Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Parties", + "format": "bulleted_list", + "prompt": "Identify all parties to this share purchase agreement. For each, state their full legal name, jurisdiction of incorporation (if stated), and their role (e.g. seller, buyer, target company, warrantor, guarantor)." + }, + { + "index": 1, + "name": "Date", + "format": "date", + "prompt": "What is the date of this share purchase agreement?" + }, + { + "index": 2, + "name": "Transaction", + "format": "text", + "prompt": "Summarise the transaction. What shares or interests are being acquired, in which target company or companies, and what is the nature of the transaction (e.g. 100% acquisition, majority stake, minority investment)?" + }, + { + "index": 3, + "name": "Consideration", + "format": "monetary_amount", + "prompt": "What is the consideration payable under this agreement? State the total headline price, the currency, and the structure (e.g. cash, shares, loan notes, deferred consideration, earnout). If the price is subject to adjustment (e.g. locked box, completion accounts), describe the mechanism." + }, + { + "index": 4, + "name": "Key Conditions Precedent", + "format": "bulleted_list", + "prompt": "List the key conditions precedent (CPs) to completion. For each CP, state what must be satisfied or waived and by whom. Identify any long-stop date by which CPs must be satisfied." + }, + { + "index": 5, + "name": "Completion Date", + "format": "text", + "prompt": "When does completion occur? State how many business days after satisfaction or waiver of all CPs completion must occur, and/or any fixed outside date for completion. Note whether there is any obligation to complete by a specific date after signing." + }, + { + "index": 6, + "name": "Warranties", + "format": "text", + "prompt": "Summarise the warranty package. Who gives the warranties (e.g. seller, management, all sellers jointly and severally)? Are there business warranties and/or title warranties? Identify the scope of any warranty disclosure process and any limitations on warranty claims (e.g. time limits, minimum claim thresholds, aggregate cap)." + }, + { + "index": 7, + "name": "Indemnities", + "format": "text", + "prompt": "Are there specific indemnities in this agreement? If so, list the key indemnities given, by whom, and for what potential liabilities (e.g. tax indemnity, environmental indemnity, litigation indemnity). Note any time limits or caps applicable to indemnity claims." + }, + { + "index": 8, + "name": "Limitation of Liability", + "format": "text", + "prompt": "What limitations on liability apply to warranty and indemnity claims? Identify the aggregate cap (and how it is calculated, e.g. as a percentage of consideration), any separate cap for fundamental warranties or indemnities, minimum claim thresholds (de minimis and basket/deductible), and time limits for bringing claims." + }, + { + "index": 9, + "name": "Covenants", + "format": "text", + "prompt": "What restrictive or other covenants are given by the seller or management? Include non-compete, non-solicitation, and non-dealing covenants, stating the scope (activities and geography) and duration of each." + }, + { + "index": 10, + "name": "Exclusivity", + "format": "text", + "prompt": "Is there an exclusivity or no-shop provision in this agreement? If so, state the period of exclusivity, what activities are restricted (e.g. soliciting competing offers, engaging with third parties), and any carve-outs or break fee arrangements." + }, + { + "index": 11, + "name": "Governing Law and Jurisdiction", + "format": "text", + "prompt": "What governing law applies to this agreement and what courts or arbitral tribunals have jurisdiction? State the chosen law, the forum for disputes, and whether jurisdiction is exclusive or non-exclusive." + }, + { + "index": 12, + "name": "Dispute Resolution", + "format": "text", + "prompt": "How are disputes to be resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen seat or forum, the applicable rules (if arbitration), and any mandatory pre-dispute escalation steps." + } + ] + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-supply-agreement-tabular-review", + "metadata": { + "title": "Supply Agreement Tabular Review", + "description": "Use this workflow to review uploaded documents and extract structured information into the tabular review columns defined in table-config.yaml.", + "type": "tabular", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Supply Agreement Tabular Review\n\n## Purpose\n\nUse this workflow to review uploaded documents and extract structured information into the tabular review columns defined in `table-config.yaml`.\n\n## Instructions\n\n- Apply each column prompt in `table-config.yaml` to each document independently.\n- Extract only information supported by the document text.\n- Use clause references, section names, dates, amounts, party names, and defined terms where available.\n- If responsive information is not found, return an empty value or a concise \"Not found\" response according to the host system's expected format.\n- Keep cell outputs concise, but include enough context to make the extracted value useful.\n- Do not invent citations, facts, parties, dates, or obligations.", + "columns_config": [ + { + "index": 0, + "name": "Parties", + "format": "bulleted_list", + "prompt": "Identify all parties to this supply agreement. For each, state their full legal name, jurisdiction of incorporation (if stated), and their role (e.g. supplier, buyer, distributor)." + }, + { + "index": 1, + "name": "Effective Date", + "format": "date", + "prompt": "What is the effective date or commencement date of this agreement? If no explicit date is stated, note the date it is deemed to take effect." + }, + { + "index": 2, + "name": "Products", + "format": "bulleted_list", + "prompt": "What products are to be supplied under this agreement? List each product or product category, including any relevant specifications, part numbers, or standards referenced." + }, + { + "index": 3, + "name": "Term", + "format": "text", + "prompt": "What is the initial term or duration of this agreement? State the start date (or reference to when it commences) and the end date or duration." + }, + { + "index": 4, + "name": "Renewal", + "format": "text", + "prompt": "What renewal provisions apply? Is renewal automatic or by agreement? State the renewal period, notice requirements to prevent renewal, and any conditions on renewal." + }, + { + "index": 5, + "name": "Delivery", + "format": "text", + "prompt": "What delivery obligations and terms apply? Identify the delivery terms (e.g. Incoterms), delivery lead times, delivery locations, risk of loss, and any consequences for late or failed delivery." + }, + { + "index": 6, + "name": "Quality", + "format": "text", + "prompt": "What quality standards or specifications apply to the products? Identify any applicable standards (e.g. ISO, regulatory requirements), inspection rights, acceptance procedures, and consequences of non-conformance." + }, + { + "index": 7, + "name": "Warranties", + "format": "text", + "prompt": "What warranties does the supplier give in relation to the products? State the warranty period, the scope of the warranty (e.g. free from defects, conformance to specifications), the remedy for breach (e.g. repair, replacement, refund), and any exclusions." + }, + { + "index": 8, + "name": "Liquidated Damages", + "format": "text", + "prompt": "Are there any liquidated damages provisions? If so, identify what triggers them (e.g. late delivery, failure to meet quality standards), the applicable rate or formula, any aggregate cap, and whether they are stated to be the exclusive remedy." + }, + { + "index": 9, + "name": "Limitation of Liability", + "format": "text", + "prompt": "What limitations of liability apply? Identify any caps on liability (and how they are calculated, e.g. contract value, fees paid), exclusions of consequential or indirect loss, and any carve-outs from the limitation (e.g. fraud, wilful misconduct, death or personal injury)." + }, + { + "index": 10, + "name": "Force Majeure", + "format": "text", + "prompt": "Summarise the force majeure clause. What events qualify, what obligations are suspended, what notice must be given, how long must the event persist before either party may terminate, and what are the consequences of termination for force majeure?" + }, + { + "index": 11, + "name": "Termination Rights", + "format": "text", + "prompt": "What are the termination rights of each party? Distinguish between termination for convenience (including notice period) and termination for cause (including cure periods and triggers). Note what happens on termination, including any outstanding purchase orders or payment obligations." + }, + { + "index": 12, + "name": "Governing Law", + "format": "text", + "prompt": "What governing law applies to this agreement? State the jurisdiction and any specific legal system referenced." + }, + { + "index": 13, + "name": "Dispute Resolution", + "format": "text", + "prompt": "How are disputes resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen forum or seat, and any mandatory escalation steps (e.g. negotiation, mediation) before formal proceedings." + } + ] + } +]; + +export const SYSTEM_WORKFLOW_IDS = new Set(SYSTEM_WORKFLOWS.map((wf) => wf.id)); + +export const SYSTEM_ASSISTANT_WORKFLOWS: { id: string; title: string; skill_md: string }[] = [ + { + "id": "builtin-commercial-lease-review", + "title": "Commercial Lease Review", + "skill_md": "# Commercial Lease Review\n\n## Instructions\n\nReview the uploaded commercial lease and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — reinstatement obligation is uncapped\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Break Rights | High — Clause [x] imposes vacant possession as a break condition with no cure period. The lessee-specific checklist point indicates the break is fragile and may be lost on a technicality. | For the lessee, remove vacant possession as a break condition or add a cure period and express the condition narrowly. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the premises. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, premises description, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Lessor or Lessee).\n\n| Issue | General | Lessor | Lessee |\n| --- | --- | --- | --- |\n| Parties and Premises | Identify landlord, tenant, guarantor, property, premises, title references, and included or excluded areas. Flag incorrect entities, missing plans, or premises description inconsistent with title. | | |\n| Term | State commencement, expiry, rent commencement, and any conditions precedent. | Flag renewal rights or holding-over provisions that limit the landlord's ability to recover the premises. | Flag uncertain commencement mechanics, no renewal clarity, or landlord-only discretion over start conditions. |\n| Rent and Payments | Confirm rent quantum, payment dates, and deposit mechanics. | Flag unclear payment mechanics, weak late-payment provisions, or rent-free periods exceeding what was agreed. | Flag unclear payment obligations, high default interest, or missing rent-free mechanics. |\n| Rent Review | Identify review dates, mechanism, assumptions, disregards, and dispute process. | Flag review mechanics too favorable to the tenant, downward review provisions, or no upward adjustment. | Flag upward-only review, no cap, aggressive assumptions, or tenant-unfriendly dispute process. |\n| Service Charge and Operating Costs | Identify tenant contribution scope, cap mechanics, audit rights, and reconciliation process. | Flag cost exclusions too broad, caps too low, or audit rights creating undue operational burden. | Flag broad pass-throughs, no cap, capital expenditure exposure, or weak audit rights. |\n| Use | State permitted use, prohibited uses, trading obligations, and change-of-use restrictions. | Flag permitted use too broad or absence of continuous trading obligation. | Flag narrow use restriction, continuous trading obligations, or restrictions inconsistent with operations. |\n| Repairs and Maintenance | Identify landlord and tenant repair obligations, condition standard, and schedule of condition. | Flag limited tenant repairing obligations, repair gaps, or yielding-up conditions below acceptable standard. | Flag full repairing obligation extending to pre-existing defects or structural issues outside tenant control. |\n| Alterations and Fit-Out | Identify consent requirements and reinstatement obligations. | Flag broad alteration rights without landlord consent or no reinstatement obligation. | Flag absolute consent rights, broad reinstatement obligations, or unclear fit-out approval timing. |\n| Assignment, Underletting, and Sharing Occupation | Identify transfer restrictions, consent tests, and group sharing rights. | Flag broad permitted transfer rights allowing assignment to unsuitable tenants without approval. | Flag absolute assignment bans, excessive consent conditions, or no group sharing rights. |\n| Insurance and Damage | Identify who insures, insured risks, rent suspension, and termination rights on damage. | Flag tenant insurance obligations too narrow or absence of rent suspension protection. | Flag no rent suspension on damage, uninsured risk exposure, or no termination right after prolonged damage. |\n| Break Rights | Identify break dates, notice periods, and conditions. | Flag broadly exercisable break conditions allowing tenant exit without adequate penalty. | Flag fragile break conditions, strict vacant possession tests, or excessive preconditions. |\n| Default and Remedies | Identify default events, cure periods, and enforcement rights. | Flag long cure periods, high default thresholds, or limited remedies that delay enforcement. | Flag short cure periods, broad default triggers, or landlord self-help without adequate notice. |\n| Compliance Obligations | Identify which party bears responsibility for regulatory compliance. | Flag compliance gaps exposing the landlord to regulatory liability. | Flag tenant responsibility for landlord works, historic contamination, or compliance beyond the demised premises. |\n| Security Package | Identify security type, quantum, and release conditions. | Flag security inadequate for the tenant's covenant strength or release mechanics too easy to trigger. | Flag excessive security requirements, no step-down on release, or unclear deposit return mechanics. |\n| Rights Reserved and Easements | Identify reserved landlord rights, access arrangements, and tenant rights over common parts. | Flag insufficient reserved rights limiting the landlord's ability to manage the building. | Flag broad landlord entry or disruption rights, or missing rights needed for the tenant's permitted use. |\n| End of Term | Identify yielding-up condition, reinstatement obligations, and dilapidations process. | Flag inadequate reinstatement obligations, unclear yielding-up conditions, or weak dilapidations recovery. | Flag overly broad reinstatement obligations or handback conditions requiring a better standard than at commencement. |\n| Governing Law and Dispute Resolution | State governing law, court forum, expert determination process, and mandatory dispute procedures. Flag ambiguous forum, missing expert determination terms, or inadequate dispute mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." + }, + { + "id": "builtin-corporate-approvals-review", + "title": "Corporate Approvals Review", + "skill_md": "# Corporate Approvals Review\n\n## Instructions\n\nReview the uploaded transaction document, resolution, authority package, or corporate action to determine whether the relevant company approvals appear complete, internally consistent, and supported by the company's corporate records.\n\nBefore producing the review, ask the user to upload or identify the available corporate records to check against. Request the relevant records, including as applicable:\n\n- certificate of incorporation, certificate of formation, or equivalent constitutional filing\n- memorandum and articles, articles of association, bylaws, operating agreement, constitution, or equivalent governing document\n- register of directors, board register, officer register, or equivalent management records\n- register of shareholders, register of members, stock ledger, cap table, or equivalent ownership records\n- board minutes, written resolutions, consents, committee resolutions, shareholder resolutions, or member approvals\n- shareholder agreement, investors' rights agreement, voting agreement, operating agreement, or other approval-rights document\n- incumbency certificate, secretary's certificate, specimen signatures, powers of attorney, delegated authority matrix, or signing authority evidence\n- relevant filings, good standing certificates, prior approvals, or transaction-specific authority documents\n\nIf records are missing, do not assume they exist. Identify what is missing and explain how that limits the review.\n\nProduce a concise Markdown table with exactly these columns:\n\n- Issue\n- Records Checked\n- Finding\n- Risk\n- Recommended Action\n\nUse these risk ratings in the **Risk** column:\n\n- Low: approval position appears standard or no material issue was identified from the records provided.\n- Medium: a gap, ambiguity, or missing supporting record should be clarified before completion.\n- High: a material approval, authority, capacity, or consistency issue may affect execution, validity, enforceability, or closing.\n- Critical: an apparent absence of required approval or authority may block signing or completion unless resolved.\n\nReview the uploaded materials for these categories:\n\n| Category | What to Check |\n| --- | --- |\n| Company Identity and Capacity | Check that the company's legal name, registration number, jurisdiction, entity type, capacity, and status match the corporate records and transaction documents. |\n| Constitutional Authority | Check whether the governing documents permit the transaction, execution of documents, borrowing, guarantees, security, share actions, asset disposals, or other relevant corporate action. |\n| Board Composition and Authority | Check current directors or managers against the board register, appointment records, quorum requirements, conflicts rules, voting thresholds, and authority to approve the transaction. |\n| Shareholder or Member Approvals | Check whether shareholder, member, class, investor, reserved matter, or special approval is required by law, governing documents, shareholder agreements, or other approval-rights documents. |\n| Signing Authority | Check signatories against incumbency records, delegated authority, resolutions, powers of attorney, secretary's certificates, and execution blocks. |\n| Approval Documents | Check board minutes, written resolutions, consents, certificates, and approval packs for correct parties, dates, quorum, votes, conflicts, recitals, authorized documents, and authorized signatories. |\n| Registers and Ownership Records | Check shareholder/member registers, cap tables, stock ledgers, and transfer records for consistency with approvals, voting thresholds, class rights, and parties entitled to approve. |\n| Transaction Document Consistency | Check that the documents approved match the documents being signed, including names, dates, document titles, parties, transaction description, limits, conditions, and schedules. |\n| Reserved Matters and Consent Rights | Check shareholder agreements, investor rights documents, financing documents, or other contracts for veto rights, consent rights, notice requirements, or approval thresholds. |\n| Filings and Ancillary Steps | Check whether filings, good standing certificates, registers, notices, public records updates, share certificates, or post-completion corporate records are required. |\n| Drafting and Record Inconsistencies | Flag inconsistent entity names, officer or director names, dates, document titles, approval thresholds, defined terms, numbering, cross-references, and conflicts between records. |\n\nFor each issue, cite the relevant corporate record or transaction document in the **Records Checked** column. In the **Finding** column, state what the records show and whether they support the proposed action. In the **Recommended Action** column, provide a specific cure, confirmation, document request, approval step, or drafting correction.\n\nIf no material approval issues are found, include a row stating that no material approval issue was identified based on the records provided, and separately identify any records that were not provided or assumptions that remain open.\n\nKeep the response focused on approval, authority, capacity, signing authority, and record consistency. Do not provide a broad commercial contract review unless the user asks for one." + }, + { + "id": "builtin-cp-checklist", + "title": "Draft CP Checklist", + "skill_md": "# Draft Conditions Precedent Checklist\n\nReview the uploaded credit agreement or financing document and generate a comprehensive Conditions Precedent (CP) checklist.\n\nYou 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\nStructure 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\nUse the table field in the section object (not content) for each category's rows.\n\n## Result Table Format\n\nThe document contains one section per category, each with a table in this format:\n\n### Corporate\n\n| Index | Clause Number | Clause | Status |\n| --- | --- | --- | --- |\n| 1 | Cl. 4.1(a)(i) | Certificate of incorporation and constitutional documents of the borrower |   |\n| 2 | Cl. 4.1(a)(ii) | Board resolution authorising execution of the finance documents and approving the terms of the transaction |   |\n| 3 | Cl. 4.1(a)(iii) | Specimen signatures of all authorised signatories |   |\n\n### Legal\n\n| Index | Clause Number | Clause | Status |\n| --- | --- | --- | --- |\n| 1 | Cl. 4.1(c)(i) | Legal opinion from counsel to the borrower in form and substance satisfactory to the agent |   |\n| 2 | Cl. 4.1(c)(ii) | Legal opinion from counsel to the arrangers as to the laws of the relevant jurisdiction |   |\n\nBefore 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 Review", + "skill_md": "# Credit Agreement Review\n\n## Instructions\n\nReview the uploaded credit agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — covenant headroom is tight\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause or schedule references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Financial Covenants | High — Clause [x] sets the leverage covenant at [x]x with quarterly testing and no equity cure right. The borrower-specific checklist point indicates limited headroom and no remedy for a technical breach. | For the borrower, widen the covenant threshold, add an equity cure right, and reduce testing frequency to semi-annual. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the facility amount. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, amounts, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Borrower or Lender).\n\n| Issue | General | Borrower | Lender |\n| --- | --- | --- | --- |\n| Parties | Identify all lenders, borrowers, guarantors, agents, and other finance parties with full legal names, roles, and jurisdictions. Flag missing or incorrect entities, unclear agent roles, missing authority confirmations, or transfer mechanics that could result in unknown lenders. | | |\n| Guarantors | Identify guarantors, guarantee scope, limits, and coverage requirements. | Flag uncapped guarantees, missing limits, or upstream guarantee concerns exposing the borrower group. | Flag weak guarantor coverage, missing entities, or guarantees not covering the full facility. |\n| Other Parties | Identify facility agent, security agent, arrangers, issuing banks, and other material parties. | Flag unclear agent roles or mechanics creating additional obligations on the borrower. | Flag inconsistent secured party mechanics or missing parties affecting security validity. |\n| Date of Agreement | State signing date, effective date, and conditions to effectiveness. Flag inconsistent or unclear dates or effectiveness conditions. | | |\n| Facilities | List each facility, type, tranche, availability, and key structural features. | Flag excessive lender discretion, unclear facility purpose, or mismatched terms restricting drawdown. | Flag unclear facility structure or purpose creating enforcement ambiguity. |\n| Amount | State total commitments, currencies, tranche amounts, and accordion or incremental facilities. | Flag unclear currency exposure, uncapped accordion provisions without borrower consent, or inconsistent commitment figures. | Flag uncapped increase mechanics or dilutive accordion provisions not agreed at signing. |\n| Purpose | Summarize permitted use of proceeds and restrictions. | Flag vague purpose wording restricting flexibility or creating sanctions risk. | Flag broad purpose language permitting restricted payment leakage or unapproved use of proceeds. |\n| Interest | Identify reference rate, margin, floors, fallback rates, interest periods, and default interest. | Flag high margins, aggressive floors, benchmark fallback gaps, or high default interest. | Flag unclear fallback rate mechanics or interest calculation ambiguities. |\n| Commitment Fee | State commitment, utilisation, ticking, arrangement, agency, and other fees. | Flag hidden fees, fees payable after cancellation, or unclear calculation basis. | Flag missing fee provisions or fee timing delaying recovery. |\n| Repayment Schedule | Summarize amortisation, scheduled repayments, bullet payments, and cash sweep. | Flag aggressive amortisation, unclear prepayment application, or cash sweep uncertainty. | Flag bullet repayments without adequate covenant or security protection. |\n| Maturity | State final maturity date and any extension options. | Flag short maturity, lender-only extension discretion, or mismatch with the business plan. | Flag unclear extension conditions or borrower-controlled extension mechanics. |\n| Security | Identify security package, assets, entities, perfection steps, and post-closing obligations. | Flag all-asset security beyond deal scope, onerous post-closing steps, or missing release mechanics on disposal. | Flag gaps in the security package, missing perfection steps, or inadequate post-closing obligations. |\n| Guarantees | Summarize guarantee obligations, guarantors, limitations, and release triggers. | Flag broad all-monies language, unlimited guarantees, or no release mechanics after repayment or disposal. | Flag guarantee gaps, weak limitations, or no coverage test requirements. |\n| Financial Covenants | Identify covenant metrics, thresholds, testing frequency, cure rights, and reporting. | Flag tight headroom, frequent testing, no equity cure, or ambiguous EBITDA adjustments. | Flag loose covenant thresholds or wide EBITDA adjustments obscuring true financial performance. |\n| Events of Default | Summarize default triggers, grace periods, materiality thresholds, cross-default, and MAE. | Flag low materiality thresholds, no cure periods, broad cross-default, or subjective MAE defaults. | Flag weak default triggers, long cure periods, or materiality thresholds delaying enforcement. |\n| Assignment | Summarize lender transfer rights, borrower consent mechanics, and disqualified institutions. | Flag free transfers to competitors, distressed investors, or lenders on a borrower blacklist. | Flag consent mechanics giving the borrower excessive veto over lender transfers. |\n| Change of Control | Identify triggers and resulting prepayment, cancellation, or default consequences. | Flag broad triggers, no cure period, or definitions catching ordinary-course ownership changes. | Flag narrow change of control definitions missing material ownership shifts. |\n| Prepayment Fee | Identify make-whole, soft-call, premium periods, and exceptions. | Flag excessive fees, long premium periods, or unclear exceptions for mandatory prepayments. | Flag exceptions allowing free prepayment during premium periods. |\n| Governing Law and Dispute Resolution | State governing law, jurisdiction, forum, and service of process. Flag unclear or ambiguous governing law, jurisdiction, or service mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." + }, + { + "id": "builtin-draft-issues-list", + "title": "Draft Issues List", + "skill_md": "# Draft Issues List\n\n## Instructions\n\nReview the uploaded agreement and draft a comprehensive issues list from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before drafting the issues list.\n\nAn issues list identifies open, unresolved, or contentious points in the current draft that require negotiation or resolution before signing. Focus on points that are material to the represented party's commercial and legal position.\n\nOnce the represented party is clear, use the generate_docx tool to produce the issues list as a downloadable Word document. Do not display the issues list inline — generate the .docx file and provide the download link.\n\nThe Word document must contain exactly one result table. List each open issue in order from highest to lowest priority and add a final row called **Overall Negotiation Position**. The result table must have exactly these columns:\n\n- Issue\n- Current Position\n- Proposed Change\n\nUse these priority ratings at the start of the **Current Position** column: Critical means a blocking issue that must be resolved before signing; High means a material point requiring negotiation; Medium means a negotiation concern but manageable; Low means a minor point for consideration only. Include relevant clause references in the **Current Position** column. The **Proposed Change** column must state the specific amendment or position the represented party should seek, drafted from that party's perspective. Keep the response concise and focused on actionable negotiation points.\n\n## Result Table Format\n\nThe Word document must use this result table structure. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Current Position | Proposed Change |\n| --- | --- | --- |\n| Liability Cap | High — Clause [x] contains no monetary cap on the represented party's total liability under the agreement. | Add a liability cap equal to [amount] or a multiple of fees paid, with carve-outs limited to fraud and wilful misconduct. |\n| Governing Law | Medium — Clause [x] specifies [jurisdiction] law, which is inconvenient for the represented party's operations and legal advisers. | Change governing law to [preferred jurisdiction] and amend the dispute resolution clause accordingly. |\n\nUse the table field in the document section object, not prose content, for the issues list rows.\n\nBefore finalizing, double-check that the table is formatted correctly: it must have exactly the three columns above in the same order, headers must match exactly (Issue, Current Position, Proposed Change), every row must have the same number of cells as the headers, issues must be ordered from highest to lowest priority, and no cells should contain stray markdown, newlines, or placeholder text unless the source document requires a placeholder." + }, + { + "id": "builtin-employment-agreement-review", + "title": "Employment Agreement Review", + "skill_md": "# Employment Agreement Review\n\n## Instructions\n\nReview the uploaded employment agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High - restrictive covenant scope is excessive\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to another party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Restrictive Covenants | High - Clause [x] imposes a long non-compete with broad geographic scope. The general covenant position and the employee-specific checklist point indicate enforceability and mobility risk for the employee. | For the employee, reduce the duration, territory, and restricted activities to what is necessary for legitimate business protection. |\n| Drafting Consistency | Medium - Clauses [x] and [y] use inconsistent defined terms or party names. The general drafting point indicates ambiguity that may affect the represented party's rights or obligations. | For the represented party, align the defined terms, party names, numbering, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Employer or Employee).\n\n| Issue | General | Employer | Employee |\n| --- | --- | --- | --- |\n| Parties and Role | Identify employer and employee with correct legal names. Confirm job title, reporting line, work location, and employment type. Flag incorrect entities, unclear role scope, or terms creating unintended employment relationships. | | |\n| Start Date and Term | State commencement date, probation period, fixed term, renewal mechanics, and conditions precedent. | Flag unclear commencement mechanics or terms that limit employer flexibility during the probation period. | Flag long probation periods, unclear renewal mechanics, or employer-only extension rights. |\n| Duties | Summarize duties, working hours, exclusivity, travel requirements, and flexibility clauses. | Flag overly narrow duties that restrict operational flexibility or the employer's ability to change the role. | Flag open-ended duties, excessive hours, broad travel requirements, or unilateral employer change rights. |\n| Compensation | Identify salary, payment frequency, reviews, bonus, commission, allowances, and discretion. | Flag discretionary compensation arrangements that may create enforceable entitlement expectations. | Flag fully discretionary compensation, unclear bonus criteria, or missing payment timing. |\n| Benefits | Summarize pension, insurance, leave, expenses, equity incentives, and employer discretion. | Flag benefit commitments that may be difficult to modify or withdraw without breach. | Flag benefits that can be withdrawn without notice or that conflict with the offer letter. |\n| Holiday and Leave | State annual leave, sickness absence, statutory leave, notice requirements, and carry-over rules. | Flag carry-over obligations or leave accrual that creates significant financial liability. | Flag entitlements below statutory minimums, unclear carry-over rules, or inadequate sick pay. |\n| Policies and Handbook | Identify incorporated policies, contractual status, and amendment rights. | Flag policies incorporated as contractual that cannot be changed unilaterally without employee consent. | Flag policies incorporated as contractual while the employer retains unilateral amendment rights. |\n| Confidentiality | Summarize confidentiality obligations during and after employment. | Flag confidentiality obligations too narrow to protect business-critical information. | Flag overbroad confidential information definitions or indefinite restrictions beyond legitimate business interests. |\n| Intellectual Property | Identify ownership, assignment, inventions, works, moral rights waivers, and assistance obligations. | Flag gaps in IP assignment covering inventions or works made during employment or using company resources. | Flag assignment of unrelated personal inventions, works outside employment scope, or unpaid post-employment assistance obligations. |\n| Data Protection and Monitoring | Summarize monitoring, privacy, processing, and consent provisions. | Flag monitoring arrangements that may expose the employer to data protection liability. | Flag intrusive monitoring, blanket consent provisions, or missing privacy notice references. |\n| Conflicts and Outside Activities | Identify restrictions on outside work, directorships, investments, conflicts, and disclosure duties. | Flag insufficient restrictions on outside activities or conflicts that could harm business interests. | Flag broad bans on passive investments, restrictions on unrelated outside work, or disclosure obligations for non-conflicting activities. |\n| Termination | State notice periods, payment in lieu, garden leave, summary dismissal, severance, and survival terms. | Flag notice periods or severance obligations that unduly limit employer termination flexibility. | Flag one-sided notice rights, broad summary dismissal triggers, or unclear payment in lieu treatment. |\n| Restrictive Covenants | Summarize non-compete, non-solicit, non-dealing, non-poach, and confidentiality covenants. | Flag covenants too narrow in scope, too short in duration, or likely to be unenforceable. | Flag excessive duration, broad geographic scope, wide covered customers, or covenants that may be unenforceable restraints. |\n| Return of Property | Identify obligations to return devices, documents, data, confidential information, and property. | Flag unclear scope of return obligations or no mechanism to enforce retrieval of business property. | Flag no carve-out for personal materials or obligations that require returning statutory record retention copies. |\n| Governing Law and Dispute Resolution | State governing law, courts, tribunal forum, arbitration, and mandatory procedures. Flag unfamiliar law, unclear mandatory procedures, or arbitration clauses that may limit statutory rights. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." + }, + { + "id": "builtin-guarantee-agreement-review", + "title": "Guarantee Agreement Review", + "skill_md": "# Guarantee Agreement Review\n\n## Instructions\n\nReview the uploaded guarantee, guaranty, or guarantee-and-indemnity agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High - guarantee is uncapped\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to another party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Cap and Limits | High - Clause [x] does not include a monetary cap or time limit. The general exposure point and the guarantor-specific checklist point indicate uncapped liability risk for the guarantor. | For the guarantor, add a monetary cap, currency limit, time limit, and exclusions for obligations outside the agreed transaction. |\n| Drafting Consistency | Medium - Clauses [x] and [y] use inconsistent defined terms or party names. The general drafting point indicates ambiguity that may affect the represented party's rights or obligations. | For the represented party, align the defined terms, party names, numbering, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Guarantor or Guarantee Holder).\n\n| Issue | General | Guarantor | Guarantee Holder |\n| --- | --- | --- | --- |\n| Parties | Identify all parties with full legal names and roles, including guarantor, principal obligor, agent, and security trustee. Flag incorrect entities, missing capacity details, or unclear party or agent structure. | | |\n| Guaranteed Obligations | Summarize payment, performance, indemnity, costs, interest, fees, future advances, and all-monies coverage. | Flag broad all-monies coverage, obligations beyond the intended transaction, or future advances not agreed at outset. | Flag gaps in guaranteed obligations or exclusions of fees, interest, or enforcement costs. |\n| Nature of Liability | State whether liability is primary, secondary, joint and several, continuing, independent, on-demand, or principal debtor liability. | Flag primary, on-demand, or principal debtor liability where only secondary guarantee risk was intended. | Flag secondary-only liability or absence of principal debtor language that weakens enforcement. |\n| Guarantee vs Indemnity | Identify any separate indemnity or principal debtor language and explain practical effect. | Flag separate indemnity language that expands exposure beyond the underlying obligation or bypasses guarantor defences. | Flag absence of indemnity or principal debtor language that could allow defences to defeat enforcement. |\n| Cap and Limits | State monetary cap, currency, interest cap, time limit, excluded obligations, and other limits. | Flag uncapped exposure, unclear currency, or no temporal limit on guaranteed obligations. | Flag caps too low to cover the full facility, or caps that exclude interest, fees, and enforcement costs. |\n| Continuing Security | Summarize future, contingent, amended, refinanced, reinstated, or continuing obligations. | Flag coverage of unknown future obligations or amendments to the underlying facility without guarantor consent. | Flag gaps in continuing security coverage or restrictions on amendments that limit lender flexibility. |\n| Demand Mechanics | Explain demand triggers, notice, payment timing, evidence, and conclusiveness. | Flag immediate payment without adequate evidence, conclusive certificates, or no notice period before demand. | Flag demand mechanics too cumbersome or conditions that allow the guarantor to delay payment. |\n| Defences and Waivers | Identify waivers of suretyship defences, diligence, presentment, set-off, marshalling, subrogation, contribution, and notice. | Flag broad waivers of fundamental defences, excessive restriction of set-off, or waiver of subrogation rights. | Flag incomplete waiver of suretyship defences that could allow the guarantor to avoid payment. |\n| Variations and Releases | Summarize effect of amendments, waivers, releases, insolvency events, or underlying document changes. | Flag guarantee surviving material changes to the underlying facility without guarantor consent. | Flag restrictions on amendments or waivers requiring guarantor consent that limit lender flexibility. |\n| Reinstatement | Identify clawback, preference, invalid payment, or reinstatement provisions. | Flag indefinite reinstatement obligations or reinstatement beyond reasonable insolvency clawback risk. | Flag absence of reinstatement provisions that could leave the guarantee holder exposed after a clawback. |\n| Subordination | Summarize restrictions on guarantor claims, subrogation, contribution, or proof in insolvency. | Flag indefinite blockage of guarantor recovery or subrogation rights after the guarantee holder is paid in full. | Flag absence of subordination provisions that could allow the guarantor to compete with the guarantee holder in insolvency. |\n| Representations and Covenants | Identify capacity, authority, solvency, consents, sanctions, and compliance covenants. | Flag repeating representations, broad compliance obligations, or solvency statements that may be difficult to give. | Flag weak representations or covenants that do not adequately confirm guarantor capacity and authority. |\n| Termination and Release | State how the guarantee may be terminated, released, discharged, or reduced. | Flag no release mechanism after repayment or facility termination, or unclear discharge conditions. | Flag release mechanics too easy to trigger or that do not require full repayment and discharge. |\n| Costs, Expenses, and Taxes | Summarize reimbursement, gross-up, tax indemnities, and enforcement costs. | Flag unlimited costs, gross-up for avoidable taxes, or broad indemnities beyond reasonable enforcement costs. | Flag cost recovery gaps that leave enforcement costs unrecoverable from the guarantor. |\n| Assignment and Transfer | Identify whether the guarantee holder may assign or transfer benefit and whether guarantor consent is required. | Flag free transfer of benefit to unknown creditors, distressed debt purchasers, or competitors without consent. | Flag consent mechanics that give the guarantor excessive control over assignment of the guarantee benefit. |\n| Governing Law and Dispute Resolution | State governing law, jurisdiction, arbitration, service of process, and immunity waiver provisions. Flag unfamiliar law, inconvenient forum, broad immunity waiver, unclear service mechanics, or inadequate interim relief provisions. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." + }, + { + "id": "builtin-nda-review", + "title": "NDA Review", + "skill_md": "# NDA Review\n\n## Instructions\n\nReview the uploaded non-disclosure agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High - confidential information definition is too narrow\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to another party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Definition of Confidential Information | Medium - Clause [x] covers broad categories of information but does not clearly address oral disclosures. The general definition point and the disclosing-party checklist point indicate a protection gap. | For the disclosing party, add oral and visual disclosures and require written confirmation within a defined period. |\n| Drafting Consistency | Medium - Clauses [x] and [y] use inconsistent defined terms or party names. The general drafting point indicates ambiguity that may affect the represented party's rights or obligations. | For the represented party, align the defined terms, party names, numbering, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Disclosing Party or Receiving Party).\n\n| Issue | General | Disclosing Party | Receiving Party |\n| --- | --- | --- | --- |\n| Parties | Identify each party and their role. Confirm whether the NDA is mutual or one-way and that obligations are correctly allocated. Flag missing affiliates, mismatched roles, or obligations inconsistent with the agreed structure. | | |\n| Purpose | Summarize the permitted purpose for which confidential information may be used. | Flag overly broad purpose language that permits use beyond the intended transaction. | Flag unclear or narrow purpose that prevents legitimate use of information in connection with the transaction. |\n| Definition of Confidential Information | Explain what information is protected, including oral, visual, derived, or pre-existing information. | Flag definitions too narrow to protect all disclosed information, or no protection for oral or unmarked information. | Flag definitions overbroad, capturing publicly available information or information not actually sensitive. |\n| Exclusions | Identify standard exclusions such as public domain, independently developed, already known, or third-party received information. | Flag missing standard exclusions that would allow the receiving party to escape obligations too easily. | Flag missing standard exclusions (public domain, independently developed, already known) or exclusions that are too difficult to prove. |\n| Disclosure Obligations | Summarize confidentiality obligations, standard of care, and use or disclosure restrictions. | Flag standards of care too weak to adequately protect the information. | Flag strict liability, vague standards, or obligations higher than own-information standards that expose the receiving party disproportionately. |\n| Permitted Recipients | Identify who may receive confidential information, including affiliates, representatives, advisers, financing sources, or investors. | Flag recipient categories too broad, or no requirement for recipients to be bound by equivalent obligations. | Flag missing adviser, affiliate, or financing source access rights needed to evaluate the transaction. |\n| Recipient Liability | State whether the receiving party is responsible for breaches by permitted recipients. | Flag absence of receiving party responsibility for breaches by permitted recipients. | Flag uncapped liability for actions of recipients outside the party's direct control. |\n| Compelled Disclosure | Summarize disclosure required by law, regulation, court order, stock exchange, or governmental authority. | Flag no notice right, no cooperation obligation, or overly broad compelled disclosure permissions. | Flag overly burdensome notice requirements or obligations to resist disclosure beyond what is practicable. |\n| Term | State the agreement term and the duration of confidentiality obligations. | Flag obligations that expire too soon or unclear continuation of obligations after agreement end. | Flag indefinite obligations or unusually long terms for non-trade-secret information. |\n| Return or Destruction | Summarize return, destruction, retention, and archival copy obligations. | Flag no return obligation or inadequate destruction mechanics. | Flag no carve-out for archival, compliance, backup, or legal retention copies. |\n| Residual Knowledge | Identify whether unaided memory or residual knowledge may be used. | Flag broad residual knowledge rights that could undermine confidentiality protections. | Flag absence of a residual knowledge carve-out that prevents legitimate use of unaided memory. |\n| Non-Solicit / Standstill | Identify non-solicitation, non-circumvention, standstill, exclusivity, or similar restrictions. | Flag absence of non-solicitation or standstill protections where commercially expected. | Flag hidden restrictive covenants, long durations, broad covered persons, or restrictions unrelated to confidentiality. |\n| No Warranty / No Obligation | Summarize disclaimers about accuracy, completeness, warranties, or obligation to proceed. | Flag missing disclaimers that could create liability for the accuracy or completeness of disclosed information. | Flag disclaimers that conflict with fraud or intentional misrepresentation, or that expressly exclude reliance. |\n| Remedies | Identify injunctive relief, equitable remedies, indemnities, liquidated damages, or enforcement provisions. | Flag absence of injunctive relief or inadequate remedies for breach. | Flag automatic injunction language, broad indemnities, or liquidated damages without adequate safeguards. |\n| Assignment | Summarize restrictions on assignment or transfer. | Flag free assignment rights that could transfer obligations to unknown parties. | Flag restrictions that block legitimate affiliate transfers needed for the deal process. |\n| Governing Law and Dispute Resolution | State governing law, forum, arbitration provisions, and submission to jurisdiction. Flag unfamiliar law, asymmetric process, inconvenient forum, or missing service provisions. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." + }, + { + "id": "builtin-proofread", + "title": "Proofread", + "skill_md": "# Proofread\n\n## Instructions\n\nReview the uploaded document for drafting quality, internal consistency, and mechanical errors. Focus on issues that a lawyer or reviewer should correct before the document is circulated, signed, or filed.\n\nProduce a concise Markdown table with exactly these columns:\n\n- Severity\n- Category\n- Location\n- Issue\n- Recommended Fix\n\nUse these severity ratings:\n\n- Critical: an inconsistency or drafting error that may materially change rights, obligations, parties, timing, or enforceability.\n- High: an error likely to create ambiguity, negotiation friction, or implementation risk.\n- Medium: a proofreading or consistency issue that should be fixed but is unlikely to change the main legal effect.\n- Low: a minor typo, grammar, formatting, punctuation, or style issue.\n\nReview the document for these categories:\n\n| Category | What to Check |\n| --- | --- |\n| Definitions | Check that defined terms are used consistently, all capitalized defined terms are defined, definitions are not duplicated or conflicting, definitions are not circular, unused definitions are identified, and defined terms match the operative provisions. |\n| Cross-References | Check that clause, section, schedule, exhibit, annex, and document references exist, point to the correct place, use the correct numbering, and remain accurate after any apparent drafting changes. |\n| Internal Consistency | Check for terms, sections, dates, parties, amounts, thresholds, notice periods, conditions, remedies, or obligations that conflict with each other or create inconsistent outcomes. |\n| Parties and Entity Names | Check that party names, entity suffixes, registration details, capacities, addresses, signatory names, and role labels are consistent throughout the document. |\n| Numbers, Dates, and Calculations | Check monetary amounts, percentages, dates, deadlines, time periods, notice periods, interest rates, formulas, schedules, and words-versus-figures consistency. |\n| Grammar and Typos | Check spelling, grammar, punctuation, missing words, duplicate words, repeated phrases, tense, subject-verb agreement, and obvious typographical errors. |\n| Numbering | Check clause numbering, section numbering, sub-clause hierarchy, schedule numbering, exhibit numbering, table numbering, skipped numbers, duplicate numbers, and numbering sequences. If the document has a table of contents, check that clause numbers, headings, and hierarchy are consistent with the table of contents. |\n| Formatting | Check headings, list formatting, indentation, spacing, fonts, emphasis, table formatting, schedule formatting, and inconsistent styles that may affect readability or references. |\n\nFor each issue, include the best available clause, section, page, schedule, or paragraph reference in the **Location** column. If the location is unclear, describe where the issue appears as specifically as possible.\n\nIn the **Issue** column, explain the problem and why it matters. In the **Recommended Fix** column, provide a specific correction or drafting action. Where useful, include replacement wording, but keep it concise. Do not rewrite the whole document. If no material issues are found, provide a short table row stating that no material proofreading issues were identified and note any review limitations.\n\nBefore finalizing, double-check that definitions, cross-references, numbering, formatting, and internal inconsistencies have each been considered separately. If a table of contents is present, verify that the clause numbers and headings in the document match the table of contents. Keep the response focused on actionable corrections." + }, + { + "id": "builtin-sha-summary", + "title": "Shareholder Agreement Review", + "skill_md": "# Shareholder Agreement Review\n\n## Instructions\n\nReview the uploaded shareholder agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — drag threshold is too low\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Drag-Along Rights | High — Clause [x] sets the drag threshold at [x]% and does not include a minimum price protection. The minority-specific checklist point indicates the minority can be forced to sell at an unfavorable price. | For the minority, raise the drag threshold, add a minimum price equal to fair market value, and limit warranties to proceeds received. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the share classes. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, share class definitions, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Majority or Minority).\n\n| Issue | General | Majority | Minority |\n| --- | --- | --- | --- |\n| Parties and Shareholdings | Identify each shareholder, role, share class, percentage holding, and fully diluted position. Flag inconsistent cap table information, missing parties, or unclear beneficial ownership. | | |\n| Share Classes and Rights | Summarize voting rights, dividend rights, liquidation preferences, conversion rights, redemption rights, and class consents. | Flag class consents or voting requirements that effectively give minority approval rights over ordinary decisions. | Flag hidden preference rights, disproportionate majority voting rights, or unfavorable class economics. |\n| Board Composition and Governance | Identify board size, appointment rights, observer rights, quorum, chair, and casting vote. | Flag quorum or deadlock provisions giving minority an effective veto over governance. | Flag entrenched majority appointment rights, casting vote held by a majority nominee, or no observer rights for minority. |\n| Reserved Matters | List matters requiring special majority, unanimity, investor consent, or board consent. | Flag reserved matters requiring minority consent that constrain ordinary business decisions. | Flag absence of meaningful minority veto rights or low-dollar thresholds allowing majority to structure around them. |\n| Pre-emption on New Shares | Summarize pre-emption rights, offer process, timing, exclusions, and carve-outs. | Flag mechanics slowing fundraising or requiring minority consent to issue new shares. | Flag broad carve-outs, short exercise periods, or mechanics permitting dilution without proper notice. |\n| Transfer Restrictions | Summarize lock-ups, prohibited transfers, permitted transfers, and consent requirements. | Flag restrictions preventing majority exit or group reorganisation without minority consent. | Flag absence of lock-up on majority transfers or mechanics allowing majority to exit leaving minority trapped. |\n| Right of First Refusal / Pre-emption on Transfer | Identify trigger, process, pricing, matching rights, and exceptions. | Flag long ROFR processes or pricing mechanics delaying majority exit. | Flag unclear matching rights, long processes excluding minority participation, or exceptions removing minority protections. |\n| Drag-Along Rights | Identify drag threshold, sale conditions, minority protections, and power of attorney. | Flag high drag threshold or conditions preventing majority from executing a sale. | Flag low drag threshold, no minimum price protection, forced warranties beyond proceeds received, or broad power of attorney. |\n| Tag-Along Rights | Identify triggering transfers, eligible holders, and sale terms. | Flag broad tag triggers complicating majority exit. | Flag missing or narrow tag rights, or mechanics allowing majority to exclude minority from exit proceeds. |\n| Anti-Dilution Protections | Identify anti-dilution mechanics, carve-outs, and adjustment triggers. | Flag mechanics punishing founders or majority holders on down-rounds. | Flag absence of anti-dilution protection, full ratchet terms, or unclear carve-outs from adjustment. |\n| Dividend Policy | Summarize dividend rights, preferential dividends, restrictions, and discretion. | Flag mandatory dividend obligations impairing business cash flow or restricting majority discretion. | Flag unclear preferential dividend rights or majority discretion over dividends without minority consent. |\n| Exit and Liquidity | Identify IPO, trade sale, redemption, put/call rights, timelines, and liquidation preferences. | Flag forced exit timelines or investor put rights constraining majority exit strategy. | Flag unclear waterfall on exit, exit rights not applying equally, or no minority participation in liquidity events. |\n| Deadlock | Summarize deadlock definition, escalation, expert determination, shoot-out, and liquidation mechanics. | Flag deadlock provisions giving minority disproportionate leverage or effective veto. | Flag shoot-out rights triggered at low thresholds or mechanics structurally favoring majority. |\n| Non-Compete and Non-Solicitation | Identify who is bound, restricted activities, geography, duration, and carve-outs. | Flag restraints binding the majority entity or overly broad scope applied to the majority group. | Flag broad non-competes restricting the minority's ability to operate independently after exit. |\n| Information Rights | Identify reporting, inspection, management accounts, and confidentiality obligations. | Flag extensive information rights giving minority commercially sensitive operational visibility. | Flag absence of reporting or inspection rights, or inadequate confidentiality obligations on recipients. |\n| Related Party Transactions | Identify approval requirements, disclosure duties, and arm's-length standards. | Flag overly strict conflict controls restricting legitimate majority-group transactions. | Flag weak conflict controls, broad permitted related-party dealings, or no arm's-length enforcement. |\n| Governing Law and Dispute Resolution | State governing law, forum, arbitration, escalation, and interim relief rights. Flag unclear or ambiguous forum or dispute mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." + } +]; diff --git a/backend/src/lib/userDataCleanup.ts b/backend/src/lib/userDataCleanup.ts index 8f222c6..aa812e6 100644 --- a/backend/src/lib/userDataCleanup.ts +++ b/backend/src/lib/userDataCleanup.ts @@ -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 diff --git a/backend/src/lib/userDataExport.ts b/backend/src/lib/userDataExport.ts index e2b3310..76749f0 100644 --- a/backend/src/lib/userDataExport.ts +++ b/backend/src/lib/userDataExport.ts @@ -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, diff --git a/backend/src/lib/userLookup.ts b/backend/src/lib/userLookup.ts new file mode 100644 index 0000000..de74729 --- /dev/null +++ b/backend/src/lib/userLookup.ts @@ -0,0 +1,113 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +type Db = SupabaseClient<any, "public", any>; + +export type ProfileUserInfo = { + id: string; + email: string; + display_name: string | null; +}; + +export function normalizeEmail(value: unknown) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export function normalizeDisplayName(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export async function loadProfileUsersByEmail(db: Db) { + const { data, error } = await db + .from("user_profiles") + .select("user_id, email, display_name") + .not("email", "is", null); + if (error) throw error; + + const userByEmail = new Map<string, ProfileUserInfo>(); + const userById = new Map<string, ProfileUserInfo>(); + for (const row of data ?? []) { + const email = normalizeEmail(row.email); + if (!email) continue; + const info = { + id: row.user_id as string, + email, + display_name: normalizeDisplayName(row.display_name), + }; + userByEmail.set(email, info); + userById.set(info.id, info); + } + + return { userByEmail, userById }; +} + +export async function findProfileUserByEmail(db: Db, email: string) { + const normalized = normalizeEmail(email); + if (!normalized) return null; + + const { data, error } = await db + .from("user_profiles") + .select("user_id, email, display_name") + .eq("email", normalized) + .maybeSingle(); + if (error) throw error; + if (!data) return null; + + return { + id: data.user_id as string, + email: normalized, + display_name: normalizeDisplayName(data.display_name), + }; +} + +export async function findMissingUserEmails(db: Db, emails: string[]) { + const normalizedEmails = [...new Set(emails.map(normalizeEmail).filter(Boolean))]; + if (normalizedEmails.length === 0) return []; + + const { data, error } = await db + .from("user_profiles") + .select("email") + .in("email", normalizedEmails); + if (error) throw error; + + const found = new Set( + (data ?? []) + .map((row) => normalizeEmail(row.email)) + .filter(Boolean), + ); + return normalizedEmails.filter((email) => !found.has(email)); +} + +export async function syncProfileEmail( + db: Db, + userId: string, + email: string | null | undefined, +) { + const normalizedEmail = normalizeEmail(email); + if (!userId || !normalizedEmail) return null; + + const { data: existing, error: loadError } = await db + .from("user_profiles") + .select("email") + .eq("user_id", userId) + .maybeSingle(); + if (loadError) return loadError; + + if (!existing) { + const { error } = await db.from("user_profiles").insert({ + user_id: userId, + email: normalizedEmail, + }); + return error; + } + + if (normalizeEmail(existing.email) === normalizedEmail) return null; + + const { error } = await db + .from("user_profiles") + .update({ + email: normalizedEmail, + updated_at: new Date().toISOString(), + }) + .eq("user_id", userId); + return error; +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index dac083a..b69fe17 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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; } diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 4174e44..2bb3dfd 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -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) { diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 01528f0..dd7c0de 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -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, ); } diff --git a/backend/src/routes/downloads.ts b/backend/src/routes/downloads.ts index 8a7f843..9726f86 100644 --- a/backend/src/routes/downloads.ts +++ b/backend/src/routes/downloads.ts @@ -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 diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 1a3a9ea..56ea6ef 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -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) { diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 893e255..76b7181 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -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, ); } diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index fb89a97..de9af9b 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -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( diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index e08e2aa..ca77f59 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -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; diff --git a/backend/src/routes/workflows.ts b/backend/src/routes/workflows.ts index 1ecfec7..62b28d4 100644 --- a/backend/src/routes/workflows.ts +++ b/backend/src/routes/workflows.ts @@ -1,18 +1,92 @@ 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 WorkflowType = "assistant" | "tabular"; + +type WorkflowContributor = { + name: string; + organisation: string | null; + role: string | null; + linkedin: string | null; +}; + +type WorkflowMetadata = { + title: string; + description: string | null; + type: WorkflowType; + contributors: WorkflowContributor[]; + language: string; + version: string | null; + practice: string | null; + jurisdictions: 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; + 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 +103,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 +115,103 @@ 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 workflowTypeFrom(value: unknown): WorkflowType { + return value === "tabular" ? "tabular" : "assistant"; +} + +function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata { + return { + title: workflow.title ?? "", + description: null, + type: workflowTypeFrom(workflow.type), + contributors: + normalizeContributors(workflow.contributors) ?? [ + DEFAULT_WORKFLOW_CONTRIBUTOR, + ], + language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE, + version: workflow.version ?? null, + practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE, + jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS, + }; +} + +function withDatabaseWorkflow(workflow: WorkflowRecord) { + const { + title: _title, + type: _type, + contributors: _contributors, + language: _language, + version: _version, + practice: _practice, + jurisdictions: _jurisdictions, + prompt_md, + ...rest + } = workflow; + return { + ...rest, + metadata: metadataFromWorkflowRecord(workflow), + skill_md: prompt_md ?? null, + 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, @@ -72,56 +243,166 @@ 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, + metadata: { + ...metadataFromWorkflowRecord(workflow), + contributors, + }, + skill_md: workflow.prompt_md ?? null, + columns_config: workflow.columns_config ?? null, + contributor_mode: contributorMode, + 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.metadata.type === workflowType, + ).map(withSystemWorkflowAccess); + const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter( + (workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id), + ).map(withDatabaseWorkflow); + + 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 { - title: string; - type: string; - prompt_md?: string; + const { + metadata, + skill_md, + columns_config, + } = req.body as { + metadata?: Partial<WorkflowMetadata>; + skill_md?: string; columns_config?: unknown; - practice?: string | null; }; + const title = metadata?.title; + const type = metadata?.type; if (!title?.trim()) - return void res.status(400).json({ detail: "title is required" }); - if (!["assistant", "tabular"].includes(type)) + return void res.status(400).json({ detail: "metadata.title is required" }); + if (type !== "assistant" && type !== "tabular") return void res .status(400) - .json({ detail: "type must be 'assistant' or 'tabular'" }); + .json({ detail: "metadata.type must be 'assistant' or 'tabular'" }); const db = createServerSupabase(); + devLog("[workflows/create] request", { + userId, + title: title.trim(), + type, + hasSkill: typeof skill_md === "string" && skill_md.length > 0, + columnCount: Array.isArray(columns_config) ? columns_config.length : null, + language: + normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE, + practice: metadata?.practice ?? null, + jurisdictions: + normalizeJurisdictions(metadata?.jurisdictions) ?? + DEFAULT_WORKFLOW_JURISDICTIONS, + }); const { data, error } = await db .from("workflows") .insert({ user_id: userId, title: title.trim(), type, - prompt_md: prompt_md ?? null, + prompt_md: skill_md ?? null, columns_config: columns_config ?? null, - practice: practice ?? null, - is_system: false, + language: + normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE, + practice: + normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE, + jurisdictions: + normalizeJurisdictions(metadata?.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 as WorkflowRecord)); })); async function handleWorkflowUpdate(req: Request, res: Response) { @@ -129,15 +410,21 @@ async function handleWorkflowUpdate(req: Request, res: Response) { const userEmail = res.locals.userEmail as string | undefined; const { workflowId } = req.params; const updates: Record<string, unknown> = {}; - if (req.body.title != null) updates.title = req.body.title; - if (req.body.prompt_md != null) updates.prompt_md = req.body.prompt_md; + const metadata = req.body.metadata as Partial<WorkflowMetadata> | undefined; + if (metadata?.title != null) updates.title = metadata.title; + if (req.body.skill_md != null) updates.prompt_md = req.body.skill_md; if (req.body.columns_config != null) updates.columns_config = req.body.columns_config; - if ("practice" in req.body) updates.practice = req.body.practice ?? null; + if (metadata && "language" in metadata) + updates.language = normalizeOptionalString(metadata.language); + if (metadata && "practice" in metadata) + updates.practice = metadata.practice ?? null; + if (metadata && "jurisdictions" in metadata) + updates.jurisdictions = normalizeJurisdictions(metadata.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 +433,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 +440,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 as WorkflowRecord), { allowEdit: access.allowEdit, isOwner: access.isOwner, }), @@ -171,13 +457,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 +514,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 = 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(withDatabaseWorkflow(access.workflow), { + allowEdit: access.allowEdit, + isOwner: access.isOwner, + }), + openSourceSubmission, + ), ); })); @@ -250,7 +682,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 +739,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" }); diff --git a/frontend/bun.lock b/frontend/bun.lock index 30af134..9d95d1e 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -7,6 +7,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", @@ -15,6 +17,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", @@ -28,6 +32,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", @@ -329,6 +334,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@formulajs/formulajs": ["@formulajs/formulajs@2.9.3", "", { "dependencies": { "bessel": "^1.0.2", "jstat": "^1.9.2" }, "bin": { "implementation-stats": "bin/implementation-stats" } }, "sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g=="], + + "@fortune-sheet/core": ["@fortune-sheet/core@1.0.4", "", { "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" } }, "sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw=="], + + "@fortune-sheet/formula-parser": ["@fortune-sheet/formula-parser@0.2.13", "", { "dependencies": { "@formulajs/formulajs": "^2.9.3", "tiny-emitter": "^2.1.0" } }, "sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw=="], + + "@fortune-sheet/react": ["@fortune-sheet/react@1.0.4", "", { "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" } }, "sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -745,6 +758,8 @@ "@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="], + "@tiptap/extension-table": ["@tiptap/extension-table@3.27.3", "", { "peerDependencies": { "@tiptap/core": "3.27.3", "@tiptap/pm": "3.27.3" } }, "sha512-P7iQ0SkkDWZwnXzQDOVFLXktXlX67VQ3s2YxoJYKTHlc8rPZ4UCaSogFq0G2APyBlizlY3LbMNrDeZAVFOD8wQ=="], + "@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="], "@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="], @@ -819,6 +834,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/regenerator-runtime": ["@types/regenerator-runtime@0.13.8", "", {}, "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -973,6 +990,8 @@ "bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="], + "bessel": ["bessel@1.0.2", "", {}, "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="], @@ -1429,7 +1448,7 @@ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], - "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1541,6 +1560,8 @@ "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "jstat": ["jstat@1.9.6", "", {}, "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "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=="], @@ -1593,6 +1614,8 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], @@ -1631,6 +1654,8 @@ "lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="], + "luckyexcel": ["luckyexcel@1.0.1", "", { "dependencies": { "jszip": "^3.5.0" } }, "sha512-hvbJmCXNp/vST/huA6sieDn32Ib8bd80L9aIu5ZGxniJvZle7VlpHZrl6weLGaEnX99+t7cPAoYGqrqbfZp/AQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="], @@ -1795,6 +1820,8 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1951,6 +1978,8 @@ "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], + "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], @@ -2131,6 +2160,8 @@ "terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="], + "tiny-emitter": ["tiny-emitter@2.1.0", "", {}, "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -2511,6 +2542,8 @@ "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "recharts/immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "rehype-attr/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 198e40f..a0a6a2d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 2be1583..a76e08c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,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 +25,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 +40,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", diff --git a/frontend/src/app/(pages)/account/AccountSection.tsx b/frontend/src/app/(pages)/account/AccountSection.tsx index 5858593..5fc48c9 100644 --- a/frontend/src/app/(pages)/account/AccountSection.tsx +++ b/frontend/src/app/(pages)/account/AccountSection.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; import { accountGlassSectionClassName } from "./accountStyles"; export function AccountSection({ diff --git a/frontend/src/app/(pages)/account/AccountToggle.tsx b/frontend/src/app/(pages)/account/AccountToggle.tsx index 89918a2..94df639 100644 --- a/frontend/src/app/(pages)/account/AccountToggle.tsx +++ b/frontend/src/app/(pages)/account/AccountToggle.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; import { Loader2 } from "lucide-react"; type AccountToggleSize = "sm" | "md"; diff --git a/frontend/src/app/(pages)/account/accountStyles.ts b/frontend/src/app/(pages)/account/accountStyles.ts index 82d1856..acdb84d 100644 --- a/frontend/src/app/(pages)/account/accountStyles.ts +++ b/frontend/src/app/(pages)/account/accountStyles.ts @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; export const accountGlassInputClassName = cn( "rounded-lg px-3 text-gray-900 placeholder:text-gray-400", diff --git a/frontend/src/app/(pages)/account/api-keys/page.tsx b/frontend/src/app/(pages)/account/api-keys/page.tsx index 9aa2d87..f9da3d3 100644 --- a/frontend/src/app/(pages)/account/api-keys/page.tsx +++ b/frontend/src/app/(pages)/account/api-keys/page.tsx @@ -2,12 +2,12 @@ import { useEffect, useState } from "react"; import { Eye, EyeOff } from "lucide-react"; -import { Input } from "@/components/ui/input"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +import { Input } from "@/app/components/ui/input"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { MfaVerificationPopup, needsMfaVerification, -} from "@/app/components/shared/MfaVerificationPopup"; +} from "@/app/components/popups/MfaVerificationPopup"; import { isMfaRequiredError } from "@/app/lib/mikeApi"; import { accountGlassIconButtonClassName, diff --git a/frontend/src/app/(pages)/account/connectors/page.tsx b/frontend/src/app/(pages)/account/connectors/page.tsx index 099b054..a983065 100644 --- a/frontend/src/app/(pages)/account/connectors/page.tsx +++ b/frontend/src/app/(pages)/account/connectors/page.tsx @@ -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 { Input } from "@/app/components/ui/input"; +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, diff --git a/frontend/src/app/(pages)/account/features/page.tsx b/frontend/src/app/(pages)/account/features/page.tsx index 73742fb..4ecfa4e 100644 --- a/frontend/src/app/(pages)/account/features/page.tsx +++ b/frontend/src/app/(pages)/account/features/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Check } from "lucide-react"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { AccountSection } from "../AccountSection"; export default function FeaturesPage() { diff --git a/frontend/src/app/(pages)/account/layout.tsx b/frontend/src/app/(pages)/account/layout.tsx index 558396a..8c4a187 100644 --- a/frontend/src/app/(pages)/account/layout.tsx +++ b/frontend/src/app/(pages)/account/layout.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; import { Loader2 } from "lucide-react"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/app/contexts/AuthContext"; import { accountTabButtonClassName } from "./accountStyles"; interface TabDef { diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 274f977..64e6a57 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -9,8 +9,8 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +} from "@/app/components/ui/dropdown-menu"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import type { ApiKeyState } from "@/app/lib/mikeApi"; import { MODELS, diff --git a/frontend/src/app/(pages)/account/page.tsx b/frontend/src/app/(pages)/account/page.tsx index 80453cc..5592052 100644 --- a/frontend/src/app/(pages)/account/page.tsx +++ b/frontend/src/app/(pages)/account/page.tsx @@ -3,16 +3,16 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { LogOut, Trash2 } from "lucide-react"; -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 { Button } from "@/app/components/ui/button"; +import { Input } from "@/app/components/ui/input"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; +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, diff --git a/frontend/src/app/(pages)/account/privacy-data/page.tsx b/frontend/src/app/(pages)/account/privacy-data/page.tsx index 0d3e636..2347ede 100644 --- a/frontend/src/app/(pages)/account/privacy-data/page.tsx +++ b/frontend/src/app/(pages)/account/privacy-data/page.tsx @@ -2,13 +2,13 @@ import { useState } from "react"; import { Download, Trash2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { Button } from "@/app/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, diff --git a/frontend/src/app/(pages)/account/security/page.tsx b/frontend/src/app/(pages)/account/security/page.tsx index 585c863..18d332a 100644 --- a/frontend/src/app/(pages)/account/security/page.tsx +++ b/frontend/src/app/(pages)/account/security/page.tsx @@ -8,15 +8,15 @@ import { type KeyboardEvent, } from "react"; import { Copy, Loader2 } from "lucide-react"; -import { supabase } from "@/lib/supabase"; -import { Button } from "@/components/ui/button"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +import { supabase } from "@/app/lib/supabase"; +import { Button } from "@/app/components/ui/button"; +import { useUserProfile } from "@/app/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"> diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index d088b06..059af46 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -3,7 +3,7 @@ import { useCallback, useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { PanelLeft } from "lucide-react"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/app/contexts/AuthContext"; import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext"; import { SidebarContext } from "@/app/contexts/SidebarContext"; import { PageChromeContext } from "@/app/contexts/PageChromeContext"; diff --git a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx index 58aa85f..d892292 100644 --- a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx @@ -39,24 +39,28 @@ 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 { MikeIcon } from "@/components/chat/mike-icon"; -import { useAuth } from "@/contexts/AuthContext"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +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 "@/app/components/chat/mike-icon"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { useSidebar } from "@/app/contexts/SidebarContext"; 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) => @@ -1099,15 +1116,20 @@ 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} /> ) : ( - <DocView + <PdfView key={activeTab.documentId} doc={{ document_id: activeTab.documentId }} quotes={activeQuotes ?? undefined} rounded={false} - bordered={false} /> ) ) : ( @@ -1132,7 +1154,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 +1188,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 +1213,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 +1256,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)} diff --git a/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx b/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx index 6565bd9..4ca0401 100644 --- a/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx @@ -10,7 +10,7 @@ import { useProjectWorkspace, } from "@/app/components/projects/ProjectWorkspace"; import type { Chat } from "@/app/components/shared/types"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/app/contexts/AuthContext"; interface Props { params: Promise<{ id: string }>; diff --git a/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx b/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx index 91113f2..bc3c511 100644 --- a/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx @@ -8,12 +8,13 @@ import { updateTabularReview, } from "@/app/lib/mikeApi"; import { ProjectReviewsTable } from "@/app/components/projects/ProjectReviewsTable"; +import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal"; import { ProjectSectionToolbar, useProjectWorkspace, } from "@/app/components/projects/ProjectWorkspace"; import type { TabularReview } from "@/app/components/shared/types"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/app/contexts/AuthContext"; interface Props { params: Promise<{ id: string }>; @@ -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} /> </> ); diff --git a/frontend/src/app/(pages)/tabular-reviews/page.tsx b/frontend/src/app/(pages)/tabular-reviews/page.tsx index 8e28fca..669028a 100644 --- a/frontend/src/app/(pages)/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/tabular-reviews/page.tsx @@ -16,9 +16,10 @@ 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 { useAuth } from "@/contexts/AuthContext"; +import { NewTRModal } from "@/app/components/tabular/NewTRModal"; +import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal"; +import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; +import { useAuth } from "@/app/contexts/AuthContext"; import { PageHeader } from "@/app/components/shared/PageHeader"; import { GLASS_DROPDOWN, @@ -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)} diff --git a/frontend/src/app/components/account/NewMcpModal.tsx b/frontend/src/app/components/account/NewMcpModal.tsx new file mode 100644 index 0000000..74ca36e --- /dev/null +++ b/frontend/src/app/components/account/NewMcpModal.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { Check, ChevronDown, Eye, EyeOff, Loader2 } from "lucide-react"; +import { Input } from "@/app/components/ui/input"; +import { Modal } from "@/app/components/modals/Modal"; +import type { McpConnectorSummary } from "@/app/lib/mikeApi"; +import { + accountGlassIconButtonClassName, + accountGlassInputClassName, +} from "@/app/(pages)/account/accountStyles"; + +export type NewMcpDraft = { + name: string; + serverUrl: string; + bearerToken: string; + customHeaders: string; +}; + +export type NewMcpStep = "form" | "working" | "auth" | "success"; + +interface NewMcpModalProps { + open: boolean; + draft: NewMcpDraft; + step: NewMcpStep; + result: McpConnectorSummary | null; + error: string | null; + authMessage: string | null; + showToken: boolean; + showAdvanced: boolean; + onDraftChange: (draft: NewMcpDraft) => void; + onShowTokenChange: (show: boolean) => void; + onShowAdvancedChange: (show: boolean) => void; + onClose: () => void; + onSubmit: () => Promise<void>; + onOpenConnector: (connectorId: string) => void; +} + +export function NewMcpModal({ + open, + draft, + step, + result, + error, + authMessage, + showToken, + showAdvanced, + onDraftChange, + onShowTokenChange, + onShowAdvancedChange, + onClose, + onSubmit, + onOpenConnector, +}: NewMcpModalProps) { + 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" + : "New 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 ? ( + <NewMcpSuccess connector={result} /> + ) : step === "auth" ? ( + <NewMcpAuth + message={ + authMessage ?? + "Complete authorization in the popup to finish connecting this MCP server." + } + /> + ) : ( + <div className="min-h-0 flex-1 space-y-4 overflow-y-auto pb-4"> + <p className="text-sm text-gray-500"> + The assistant will have access to this MCP server and + its enabled tools. + </p> + <NewMcpForm + draft={draft} + showToken={showToken} + showAdvanced={showAdvanced} + disabled={step === "working"} + onDraftChange={onDraftChange} + onShowTokenChange={onShowTokenChange} + onShowAdvancedChange={onShowAdvancedChange} + /> + </div> + )} + </Modal> + ); +} + +function NewMcpForm({ + draft, + showToken, + showAdvanced, + disabled, + onDraftChange, + onShowTokenChange, + onShowAdvancedChange, +}: { + draft: NewMcpDraft; + showToken: boolean; + showAdvanced: boolean; + disabled: boolean; + onDraftChange: (draft: NewMcpDraft) => void; + onShowTokenChange: (show: boolean) => void; + onShowAdvancedChange: (show: boolean) => void; +}) { + return ( + <div className="grid gap-3 pt-1"> + <label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center"> + <span className="text-xs font-medium text-gray-500"> + Label + </span> + <Input + value={draft.name} + onChange={(event) => + onDraftChange({ ...draft, name: event.target.value }) + } + placeholder="Connector label" + className={`h-8 text-sm ${accountGlassInputClassName}`} + disabled={disabled} + /> + </label> + <label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center"> + <span className="text-xs font-medium text-gray-500"> + URL endpoint + </span> + <Input + value={draft.serverUrl} + onChange={(event) => + onDraftChange({ + ...draft, + serverUrl: event.target.value, + }) + } + placeholder="https://mcp.example.com/mcp" + className={`h-8 text-sm ${accountGlassInputClassName}`} + disabled={disabled} + /> + </label> + <div className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-start"> + <span className="pt-2 text-xs font-medium text-gray-500"> + Bearer token + </span> + <div className="min-w-0"> + <div className="relative"> + <Input + value={draft.bearerToken} + onChange={(event) => + onDraftChange({ + ...draft, + bearerToken: event.target.value, + }) + } + type={showToken ? "text" : "password"} + placeholder="Bearer token" + className={`h-8 pr-10 text-sm ${accountGlassInputClassName}`} + autoComplete="off" + spellCheck={false} + disabled={disabled} + /> + {draft.bearerToken && ( + <button + type="button" + className={`absolute inset-y-1 right-1.5 flex items-center ${accountGlassIconButtonClassName}`} + onClick={() => onShowTokenChange(!showToken)} + aria-label={ + showToken ? "Hide token" : "Show token" + } + disabled={disabled} + > + {showToken ? ( + <EyeOff className="h-4 w-4" /> + ) : ( + <Eye className="h-4 w-4" /> + )} + </button> + )} + </div> + <p className="mt-1 text-right text-xs text-gray-500"> + Tokens are stored encrypted. + </p> + </div> + </div> + <div className="grid gap-2"> + <button + type="button" + onClick={() => onShowAdvancedChange(!showAdvanced)} + className="inline-flex items-center gap-1 justify-self-start text-xs font-medium text-gray-500 transition-colors hover:text-gray-900" + disabled={disabled} + > + Advanced + <ChevronDown + className={`h-3.5 w-3.5 transition-transform ${ + showAdvanced ? "" : "-rotate-90" + }`} + /> + </button> + {showAdvanced && ( + <label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-start"> + <span className="text-xs font-medium text-gray-500"> + Custom headers + </span> + <div className="min-w-0"> + <textarea + value={draft.customHeaders} + onChange={(event) => + onDraftChange({ + ...draft, + customHeaders: event.target.value, + }) + } + placeholder='{"X-API-Key":"secret"}' + className={`min-h-20 w-full resize-y rounded-lg px-3 py-2 text-sm outline-none ${accountGlassInputClassName}`} + autoComplete="off" + spellCheck={false} + disabled={disabled} + /> + <p className="mt-1 text-right text-xs text-gray-500"> + Secrets are stored encrypted. + </p> + </div> + </label> + )} + </div> + </div> + ); +} + +function NewMcpSuccess({ 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" /> + <p className="min-w-0 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 className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-100 bg-white/60"> + <div className="max-h-full overflow-y-auto divide-y divide-gray-100"> + {connector.tools.map((tool) => ( + <div + key={tool.openaiToolName} + className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-3 py-2" + > + <div className="min-w-0"> + <p className="truncate text-sm font-medium text-gray-800"> + {tool.title ?? tool.openaiToolName} + </p> + {tool.description && ( + <p className="truncate text-xs text-gray-500"> + {tool.description} + </p> + )} + </div> + <span className="text-xs text-gray-400"> + {tool.enabled ? "Enabled" : "Disabled"} + </span> + </div> + ))} + </div> + </div> + </div> + ); +} + +function NewMcpAuth({ 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> + ); +} diff --git a/frontend/src/app/components/assistant/AddDocButton.tsx b/frontend/src/app/components/assistant/AddDocButton.tsx index 5446b9b..ee80089 100644 --- a/frontend/src/app/components/assistant/AddDocButton.tsx +++ b/frontend/src/app/components/assistant/AddDocButton.tsx @@ -1,116 +1,40 @@ "use client"; -import { useRef, useState } from "react"; -import { PlusIcon, Upload, LayoutGridIcon, Loader2Icon } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { uploadStandaloneDocument } from "@/app/lib/mikeApi"; -import type { Document } from "../shared/types"; +import { PlusIcon } from "lucide-react"; interface Props { - onSelectDoc: (doc: Document) => void; onBrowseAll: () => void; selectedDocIds?: string[]; hideLabel?: boolean; } export function AddDocButton({ - onSelectDoc, onBrowseAll, selectedDocIds = [], hideLabel = false, }: Props) { - const [isOpen, setIsOpen] = useState(false); - const [uploading, setUploading] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - - const handleUpload = async (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) => uploadStandaloneDocument(f)), - ); - uploaded.forEach((doc) => onSelectDoc(doc)); - } catch (err) { - console.error("Upload failed:", err); - } finally { - setUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ""; - } - }; - return ( - <> - <input - ref={fileInputRef} - type="file" - accept=".pdf,.docx,.doc" - multiple - className="hidden" - onChange={handleUpload} - /> - <DropdownMenu onOpenChange={setIsOpen}> - <DropdownMenuTrigger asChild> - <button - className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${ - selectedDocIds.length > 0 - ? "text-black hover:bg-gray-100" - : "text-gray-400 hover:text-gray-700 hover:bg-gray-100" - } ${isOpen ? "bg-gray-100" : ""}`} - title="Add documents" - aria-label="Add documents" - > - {selectedDocIds.length > 0 ? ( - <span className="font-medium tabular-nums">{selectedDocIds.length}</span> - ) : ( - <PlusIcon - className={`h-4 w-4 shrink-0 transition-transform duration-300 ${isOpen ? "rotate-[135deg]" : ""}`} - /> - )} - <span className={hideLabel ? "hidden" : "hidden sm:inline"}> - {selectedDocIds.length === 1 - ? "Document" - : "Documents"} - </span> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent - className="w-44 z-50" - side="bottom" - align="start" - > - <DropdownMenuItem - className="cursor-pointer" - disabled={uploading} - onSelect={(e) => { - e.preventDefault(); - fileInputRef.current?.click(); - }} - > - {uploading ? ( - <Loader2Icon className="h-4 w-4 mr-2 animate-spin text-gray-400" /> - ) : ( - <Upload className="h-4 w-4 mr-2 text-gray-500" /> - )} - <span className="text-sm"> - {uploading ? "Uploading…" : "Upload files"} - </span> - </DropdownMenuItem> - <DropdownMenuItem - className="cursor-pointer" - onClick={onBrowseAll} - > - <LayoutGridIcon className="h-4 w-4 mr-2 text-gray-500" /> - <span className="text-sm">Browse all</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </> + <button + type="button" + onClick={onBrowseAll} + className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${ + selectedDocIds.length > 0 + ? "text-black hover:bg-gray-100" + : "text-gray-400 hover:text-gray-700 hover:bg-gray-100" + }`} + title="Add documents" + aria-label="Add documents" + > + {selectedDocIds.length > 0 ? ( + <span className="font-medium tabular-nums"> + {selectedDocIds.length} + </span> + ) : ( + <PlusIcon className="h-4 w-4 shrink-0" /> + )} + <span className={hideLabel ? "hidden" : "hidden sm:inline"}> + {selectedDocIds.length === 1 ? "Document" : "Documents"} + </span> + </button> ); } diff --git a/frontend/src/app/components/assistant/AskInputPopup.tsx b/frontend/src/app/components/assistant/AskInputPopup.tsx new file mode 100644 index 0000000..35a9d33 --- /dev/null +++ b/frontend/src/app/components/assistant/AskInputPopup.tsx @@ -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 "@/app/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> + ); +} diff --git a/frontend/src/app/components/assistant/AssistantMessage.tsx b/frontend/src/app/components/assistant/AssistantMessage.tsx index bf8b4e6..2c3b74a 100644 --- a/frontend/src/app/components/assistant/AssistantMessage.tsx +++ b/frontend/src/app/components/assistant/AssistantMessage.tsx @@ -1,1589 +1,52 @@ "use client"; -import { useId, useRef, useEffect, useState } from "react"; -import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; -import remarkMath from "remark-math"; -import remarkGfm from "remark-gfm"; -import rehypeKatex from "rehype-katex"; -import "katex/dist/katex.min.css"; +import { useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; +import type { AssistantEvent, Citation, EditAnnotation } from "../shared/types"; +import { EditCard } from "./EditCard"; +import { PreResponseWrapper } from "./PreResponseWrapper"; +import { ResponseStatus, type StatusState } from "./message/ResponseStatus"; +import { eventErrorMessage, toolCallLabel } from "./message/eventUtils"; +import { preprocessCitations, internalCaseHref } from "./message/citationUtils"; +import { useSmoothedReveal } from "./message/useSmoothedReveal"; +import { MarkdownContent } from "./message/MarkdownContent"; +import { CitationsBlock, buildCitationAppendix } from "./message/CitationSources"; +import { EditCardsSection } from "./message/EditCardsSection"; import { - Copy, - Check, - ChevronDown, - Download, - File, - FileText, - Loader2, - Scale, -} from "lucide-react"; -import { MikeIcon } from "@/components/chat/mike-icon"; -import { displayCitationQuote, formatCitationPage } from "../shared/types"; -import type { - AssistantEvent, - CitationAnnotation, - EditAnnotation, -} from "../shared/types"; -import { EditCard, applyOptimisticResolution } from "./EditCard"; -import { PreResponseWrapper } from "../shared/PreResponseWrapper"; -import { supabase } from "@/lib/supabase"; - -const RESPONSE_GLASS_SURFACE = - "rounded-xl border border-white/70 bg-white/55 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"; -const RESPONSE_GLASS_ANNOTATION = - "inline-flex h-4 w-4 items-center justify-center rounded-full border border-gray-200/60 bg-gray-200/80 text-[12px] font-serif font-medium text-gray-800 shadow-[0_1px_2px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(243,244,246,0.85),inset_0_-2px_4px_rgba(229,231,235,0.65)] backdrop-blur-xl transition-colors hover:bg-gray-200 hover:text-gray-950"; - -function toolCallLabel(name: string): string { - if (name === "generate_docx") return "Creating document..."; - if (name === "edit_document") return "Editing document..."; - if (name === "read_document") return "Reading document..."; - if (name === "fetch_documents") return "Reading documents..."; - if (name === "find_in_document") return "Searching document..."; - if (name === "replicate_document") return "Copying document..."; - if (name === "read_workflow") return "Loading workflow..."; - if (name === "list_workflows") return "Loading workflows..."; - if (name === "list_documents") return "Loading documents..."; - if (name === "courtlistener_search_case_law") - return "Searching case law..."; - if (name === "courtlistener_get_cases") return "Fetching cases..."; - if (name === "courtlistener_find_in_case") return "Searching case..."; - if (name === "courtlistener_read_case") return "Reading case..."; - if (name === "courtlistener_verify_citations") - return "Verifying citations..."; - if (name.startsWith("mcp_")) return "Using connector..."; - return name ? `Running ${name}...` : "Working..."; -} - -/** - * Card rendered above the per-edit EditCards when a message produced - * multiple tracked-change proposals. Lets the user resolve every pending - * edit in one click by firing the per-edit accept/reject endpoint for each - * pending annotation and forwarding each response to `onResolved` so the - * parent can bump the viewer version, persist override URLs, etc. - * - * This intentionally doesn't apply the optimistic DOM mutation that - * EditCard does — bulk operations touch many edits at once and the real - * re-render from the latest version will reconcile within a second or so. - */ -function BulkEditActions({ - pending, - filenameByDocId, - onViewClick, - onResolveStart, - onResolved, - onError, -}: { - pending: { - annotation: EditAnnotation; - filename: string; - }[]; - filenameByDocId: Map<string, string>; - onViewClick?: (ann: EditAnnotation, filename: string) => void; - 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<"accept" | "reject" | null>(null); - const [progress, setProgress] = useState<{ - done: number; - total: number; - } | null>(null); - - if (pending.length === 0) return null; - - const handleAll = async (verb: "accept" | "reject") => { - if (busy) return; - setBusy(verb); - setProgress({ done: 0, total: pending.length }); - 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"; - - // Sequential so the per-document version counter advances in a - // predictable order and the viewer doesn't race between bumps. - let done = 0; - for (const { annotation } of pending) { - onResolveStart?.({ - editId: annotation.edit_id, - documentId: annotation.document_id, - verb, - }); - // Optimistically mutate the DOM so the viewer reflects the - // resolution immediately. Revert if the backend call fails. - let revert: (() => void) | null = null; - try { - revert = applyOptimisticResolution(annotation, verb); - } catch (e) { - console.error( - "[BulkEditActions] optimistic update threw", - e, - ); - } - try { - const resp = await fetch( - `${apiBase}/single-documents/${annotation.document_id}/edits/${annotation.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"); - onResolved?.({ - editId: annotation.edit_id, - documentId: annotation.document_id, - status: nextStatus, - versionId: data.version_id, - downloadUrl: data.download_url, - }); - } catch (e) { - console.error("[BulkEditActions] resolve failed", e); - try { - revert?.(); - } catch (revertErr) { - console.error( - "[BulkEditActions] revert threw", - revertErr, - ); - } - onError?.({ - editId: annotation.edit_id, - documentId: annotation.document_id, - versionId: annotation.version_id ?? null, - message: - verb === "accept" - ? "Couldn't save one or more accepts." - : "Couldn't save one or more rejects.", - }); - } - done++; - setProgress({ done, total: pending.length }); - } - } finally { - setBusy(null); - setProgress(null); - } - }; - - // Optional: show a tiny "View first" action so bulk doesn't lose the - // in-viewer scroll-to behaviour entirely. - const first = pending[0]; - - return ( - <div className="flex items-center gap-2"> - <button - onClick={() => handleAll("accept")} - disabled={!!busy} - className="px-2 py-1 text-xs rounded border border-gray-900 bg-gray-900 text-white hover:bg-gray-800 disabled:opacity-50 inline-flex items-center gap-1" - > - {busy === "accept" && ( - <Loader2 className="h-3 w-3 animate-spin" /> - )} - Accept all - </button> - <button - onClick={() => handleAll("reject")} - disabled={!!busy} - className="px-2 py-1 text-xs rounded border border-gray-200 bg-white text-gray-700 hover:bg-gray-100 disabled:opacity-50 inline-flex items-center gap-1" - > - {busy === "reject" && ( - <Loader2 className="h-3 w-3 animate-spin" /> - )} - Reject all - </button> - {progress && ( - <span className="text-xs font-serif text-gray-500"> - {progress.done}/{progress.total} - </span> - )} - {onViewClick && first && ( - <button - onClick={() => - onViewClick(first.annotation, first.filename) - } - disabled={!!busy} - 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" - > - View - </button> - )} - </div> - ); -} - -/** - * Wraps the bulk accept/reject card and the per-edit EditCards in a single - * minimisable container. The bulk actions and summary stay visible in the - * header; the individual cards collapse via the chevron toggle. - */ -function EditCardsSection({ - pending, - filenameByDocId, - cards, - resolvedCount, - onViewClick, - onResolveStart, - onResolved, - onError, -}: { - pending: { - annotation: EditAnnotation; - filename: string; - }[]; - filenameByDocId: Map<string, string>; - cards: React.ReactNode[]; - resolvedCount: number; - onViewClick?: (ann: EditAnnotation, filename: string) => void; - 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 [isOpen, setIsOpen] = useState(true); - if (cards.length === 0) return null; - - const docCount = filenameByDocId.size; - const summary = - pending.length > 0 - ? docCount > 1 - ? `${pending.length} tracked changes across ${docCount} documents` - : `${pending.length} tracked ${pending.length === 1 ? "change" : "changes"}` - : docCount > 1 - ? `${resolvedCount} resolved tracked changes across ${docCount} documents` - : `${resolvedCount} resolved tracked ${resolvedCount === 1 ? "change" : "changes"}`; - - return ( - <div className="border border-gray-200 rounded-lg bg-white overflow-hidden"> - {/* Row 1: summary + chevron */} - <div className="flex items-center gap-2 px-3 pt-3"> - <p className="flex-1 min-w-0 text-sm font-serif text-gray-700 truncate"> - {summary} - </p> - <button - onClick={() => setIsOpen((v) => !v)} - aria-label={isOpen ? "Collapse edits" : "Expand edits"} - className="shrink-0 rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 transition-colors" - > - <ChevronDown - className={`h-4 w-4 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`} - /> - </button> - </div> - {/* Row 2: bulk action buttons */} - {pending.length > 0 && ( - <div className="px-3 pt-3"> - <BulkEditActions - pending={pending} - filenameByDocId={filenameByDocId} - onViewClick={onViewClick} - onResolveStart={onResolveStart} - onResolved={onResolved} - onError={onError} - /> - </div> - )} - {/* Row 3: collapsible cards list */} - {isOpen && ( - <div className="flex flex-col gap-2 px-3 pb-3 pt-3"> - {cards} - </div> - )} - {!isOpen && <div className="pb-3" />} - </div> - ); -} - -// --------------------------------------------------------------------------- -// ResponseStatus -// --------------------------------------------------------------------------- - -type StatusState = "active" | "error" | null; - -function ResponseStatus({ status }: { status: StatusState }) { - const [showDone, setShowDone] = useState(false); - const [doneVisible, setDoneVisible] = useState(false); - const wasActiveRef = useRef(false); - - const isActive = status === "active"; - const isError = status === "error"; - - useEffect(() => { - if (wasActiveRef.current && !isActive) { - setShowDone(true); - setDoneVisible(true); - const t = setTimeout(() => setDoneVisible(false), 1500); - return () => clearTimeout(t); - } else if (!wasActiveRef.current && isActive) { - setShowDone(false); - setDoneVisible(false); - } - wasActiveRef.current = isActive; - }, [isActive]); - - return ( - <div className="w-full h-9 flex items-center mb-2"> - <MikeIcon - spin={isActive} - done={showDone && doneVisible} - error={isError} - mike={!isError && !(showDone && doneVisible)} - size={22} - /> - </div> - ); -} - -function eventErrorMessage(event: AssistantEvent): string | null { - if (event.type === "error") return event.message; - if ("error" in event && typeof event.error === "string" && event.error) { - return event.error; - } - return null; -} - -// --------------------------------------------------------------------------- -// Event block components -// --------------------------------------------------------------------------- - -const THINKING_PHRASES = [ - "Thinking...", - "Pondering...", - "Analyzing...", - "Reviewing...", - "Reasoning...", -]; -const REASONING_COLLAPSED_MAX_LINES = 6; -const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9; - -function ReasoningBlock({ - text, - isStreaming, - showConnector, -}: { - text: string; - isStreaming: boolean; - showConnector?: boolean; -}) { - const [isContentOpen, setIsContentOpen] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - const [userToggledContent, setUserToggledContent] = useState(false); - const [isOverflowing, setIsOverflowing] = useState(false); - const [hasMeasured, setHasMeasured] = useState(false); - const [thinkingIndex, setThinkingIndex] = useState(0); - const contentRef = useRef<HTMLDivElement | null>(null); - - useEffect(() => { - if (!isStreaming) return; - const interval = setInterval(() => { - setThinkingIndex((i) => (i + 1) % THINKING_PHRASES.length); - }, 2000); - return () => clearInterval(interval); - }, [isStreaming]); - - useEffect(() => { - const el = contentRef.current; - if (!el) return; - const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24; - const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES; - const nextOverflowing = el.scrollHeight > maxHeight + 2; - setIsOverflowing(nextOverflowing); - setHasMeasured(true); - if (!userToggledContent) setIsContentOpen(isStreaming); - if (!nextOverflowing) setIsExpanded(false); - }, [isStreaming, text, userToggledContent]); - - const showContent = isContentOpen || isStreaming || !hasMeasured; - const isCollapsed = isContentOpen && isOverflowing && !isExpanded; - - return ( - <div className="relative"> - {showConnector && ( - <div className="absolute left-0 top-0 bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - <button - onClick={() => { - if (isStreaming) return; - setUserToggledContent(true); - setIsContentOpen((v) => !v); - }} - className="flex items-center text-sm font-serif text-gray-500 hover:text-gray-600 transition-colors" - > - {isStreaming ? ( - <div className="w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - ) : ( - <div className="w-1.5 h-1.5 rounded-full bg-gray-300 shrink-0" /> - )} - <span className="font-medium ml-2"> - {isStreaming - ? THINKING_PHRASES[thinkingIndex] - : "Thought process"} - </span> - {!isStreaming && ( - <ChevronDown - size={10} - className={`relative top-px ml-1 transition-transform duration-200 ${isContentOpen ? "" : "-rotate-90"}`} - /> - )} - </button> - {showContent && ( - <div className="mt-2 ml-[14px]"> - <div - className={`relative ${isCollapsed ? "overflow-hidden" : ""}`} - style={ - isCollapsed - ? { - maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`, - } - : undefined - } - > - <div - ref={contentRef} - className="text-sm font-serif text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm" - > - <ReactMarkdown - remarkPlugins={[remarkGfm]} - components={{ - code: ({ node, ...props }) => ( - <code - className="font-serif text-gray-600" - {...props} - /> - ), - }} - > - {text} - </ReactMarkdown> - </div> - {isCollapsed && ( - <> - <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" /> - <button - type="button" - onClick={() => setIsExpanded(true)} - className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600" - aria-label="Expand thought process" - > - <ChevronDown className="h-3.5 w-3.5" /> - </button> - </> - )} - </div> - {isOverflowing && isContentOpen && isExpanded && ( - <button - type="button" - onClick={() => setIsExpanded(false)} - className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600" - aria-label="Minimise thought process" - > - <ChevronDown className="h-3.5 w-3.5 rotate-180" /> - </button> - )} - </div> - )} - </div> - ); -} - -function DocReadBlock({ - filename, - onClick, - showConnector, - isStreaming, -}: { - filename: string; - onClick?: () => void; - showConnector?: boolean; - isStreaming?: boolean; -}) { - return ( - <div className="flex items-start text-sm font-serif text-gray-500 relative"> - {showConnector && ( - <div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - {isStreaming ? ( - <div className="mt-2 w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - ) : ( - <div className="mt-2 w-1.5 h-1.5 rounded-full bg-green-400 shrink-0" /> - )} - <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> - <span className="font-medium"> - {isStreaming ? "Reading" : "Read"} - </span>{" "} - {isStreaming ? ( - <span>{filename}...</span> - ) : onClick ? ( - <button - onClick={onClick} - className="text-left hover:text-gray-700 transition-colors cursor-pointer" - > - {filename} - </button> - ) : ( - <span>{filename}</span> - )} - </div> - </div> - ); -} - -function DocFindBlock({ - filename, - query, - totalMatches, - isStreaming, - showConnector, -}: { - filename: string; - query: string; - totalMatches: number; - isStreaming?: boolean; - showConnector?: boolean; -}) { - const label = isStreaming ? "Finding" : "Found"; - const matchSuffix = isStreaming - ? "" - : ` (${totalMatches} ${totalMatches === 1 ? "match" : "matches"})`; - return ( - <div className="flex items-start text-sm font-serif text-gray-500 relative"> - {showConnector && ( - <div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - {isStreaming ? ( - <div className="mt-2 w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - ) : ( - <div - className={`mt-2 w-1.5 h-1.5 rounded-full shrink-0 ${totalMatches > 0 ? "bg-green-400" : "bg-gray-300"}`} - /> - )} - <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> - <span className="font-medium">{label}</span>{" "} - <span> - “{query}”{matchSuffix} - <span className="ml-1 text-gray-400">in {filename}</span> - {isStreaming && "..."} - </span> - </div> - </div> - ); -} - -function DocCreatedBlock({ - filename, - showConnector, - isStreaming, -}: { - filename: string; - showConnector?: boolean; - isStreaming?: boolean; -}) { - return ( - <div className="flex items-start text-sm font-serif text-gray-500 relative"> - {showConnector && ( - <div className="absolute left-0 top-0 bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - {isStreaming ? ( - <div className="mt-2 w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - ) : ( - <div className="mt-2 w-1.5 h-1.5 rounded-full bg-green-400 shrink-0" /> - )} - <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> - <span className="font-medium"> - {isStreaming ? "Creating" : "Created"} - </span>{" "} - <span>{isStreaming ? `${filename}...` : filename}</span> - </div> - </div> - ); -} - -function DocReplicatedBlock({ - filename, - count, - showConnector, - isStreaming, - hasError, -}: { - filename: string; - /** - * How many consecutive replicates of this same source got collapsed - * into this block. ≥ 1; only rendered when > 1. - */ - count: number; - showConnector?: boolean; - isStreaming?: boolean; - hasError?: boolean; -}) { - const label = isStreaming ? "Replicating" : "Replicated"; - const suffix = - !isStreaming && count > 1 - ? ` ${count} times` - : isStreaming - ? "..." - : ""; - return ( - <div className="flex items-start text-sm font-serif text-gray-500 relative"> - {showConnector && ( - <div className="absolute left-0 top-0 bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - {isStreaming ? ( - <div className="mt-2 w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - ) : ( - <div - className={`mt-2 w-1.5 h-1.5 rounded-full shrink-0 ${hasError ? "bg-red-400" : "bg-green-400"}`} - /> - )} - <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> - <span className="font-medium">{label}</span>{" "} - <span> - {filename} - {suffix} - </span> - </div> - </div> - ); -} - -function DocDownloadBlock({ - filename, - download_url, - onOpen, - isReloading = false, - versionNumber, -}: { - filename: string; - download_url: string; - onOpen?: () => void; - isReloading?: boolean; - versionNumber?: number | null; -}) { - const hasVersion = - typeof versionNumber === "number" && - Number.isFinite(versionNumber) && - versionNumber > 0; - const extMatch = filename.match(/\.(\w+)$/); - const ext = extMatch ? extMatch[1].toUpperCase() : "FILE"; - const rawBasename = extMatch - ? filename.slice(0, -extMatch[0].length) - : filename; - // Strip any legacy "[Edited V3]" suffix that may still be baked into - // older saved download filenames — the version is surfaced as a - // separate tag now. - const basename = rawBasename.replace(/\s*\[Edited V\d+\]\s*$/, "").trim(); - // Only backend-relative URLs are accepted. The download fetch carries - // the user's bearer token, so any absolute URL from tool output is - // refused to keep the token from leaking off-origin. - const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; - const isSafeHref = download_url.startsWith("/"); - const href = isSafeHref ? `${API_BASE}${download_url}` : null; - const [busy, setBusy] = useState(false); - - const handleDownload = async (e?: { - stopPropagation?: () => void; - preventDefault?: () => void; - }) => { - e?.stopPropagation?.(); - e?.preventDefault?.(); - if (busy || isReloading || !href) return; - setBusy(true); - try { - const { - data: { session }, - } = await supabase.auth.getSession(); - const token = session?.access_token; - const resp = await fetch(href, { - 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; - - const body = ( - <div className="flex items-center gap-3 px-4 py-3 min-w-0 flex-1"> - <div className="min-w-0 flex-1"> - <div className="flex items-center gap-2 min-w-0"> - <p className="text-base font-serif text-gray-900 text-wrap"> - {basename} - </p> - {hasVersion && ( - <span className="shrink-0 inline-flex items-center rounded-md border border-white/70 bg-white/55 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl"> - V{versionNumber} - </span> - )} - </div> - <p className="text-xs text-blue-500 mt-0.5">{ext}</p> - </div> - </div> - ); - - const downloadIcon = spinning ? ( - <div - aria-disabled - className="shrink-0 flex items-center bg-white/25 px-6 text-gray-400 cursor-not-allowed" - > - <Loader2 size={13} className="animate-spin" /> - </div> - ) : ( - <button - type="button" - onClick={handleDownload} - className="shrink-0 flex items-center bg-white/25 px-6 text-gray-500 transition-colors hover:bg-white/55 hover:text-gray-700 cursor-pointer" - > - <Download size={13} /> - </button> - ); - - if (onOpen) { - return ( - <div - className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} - > - <button - type="button" - onClick={onOpen} - className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer" - > - {body} - </button> - {downloadIcon} - </div> - ); - } - - if (spinning) { - return ( - <div - className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} - > - {body} - {downloadIcon} - </div> - ); - } - - return ( - <div - className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} - > - <button - type="button" - onClick={handleDownload} - className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer" - > - {body} - </button> - {downloadIcon} - </div> - ); -} - -function WorkflowAppliedBlock({ - title, - showConnector, - onClick, -}: { - title: string; - showConnector?: boolean; - onClick?: () => void; -}) { - return ( - <div className="flex items-start text-sm font-serif text-gray-500 relative"> - {showConnector && ( - <div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - <div className="mt-2 w-1.5 h-1.5 rounded-full bg-green-400 shrink-0" /> - <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> - <span className="font-medium">Applied Workflow</span>{" "} - {onClick ? ( - <button - onClick={onClick} - className="text-left hover:text-gray-700 transition-colors cursor-pointer" - > - {title} - </button> - ) : ( - <span>{title}</span> - )} - </div> - </div> - ); -} - -type CourtListenerBlockItem = { - caseName: string | null; - citation: string | null; - dateFiled?: string | null; - url?: string | null; - query?: string; - totalMatches?: number; - hasError?: boolean; -}; - -function CourtListenerBlock({ - label, - detail, - isStreaming, - hasError, - showConnector, - items, -}: { - label: string; - detail?: string; - isStreaming?: boolean; - hasError?: boolean; - showConnector?: boolean; - items?: CourtListenerBlockItem[]; -}) { - const [isOpen, setIsOpen] = useState(false); - const hasItems = !!items && items.length > 0; - return ( - <div className="relative"> - {showConnector && ( - <div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - <div className="flex items-start text-sm font-serif text-gray-500"> - {isStreaming ? ( - <div className="mt-2 w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - ) : ( - <div - className={`mt-2 w-1.5 h-1.5 rounded-full shrink-0 ${hasError ? "bg-red-500" : "bg-green-400"}`} - /> - )} - <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> - {hasItems ? ( - <button - onClick={() => setIsOpen((v) => !v)} - className="text-left hover:text-gray-700 transition-colors inline-flex items-center" - > - <span className="font-medium">{label}</span> - {detail ? <span> {detail}</span> : null} - {isStreaming ? <span>...</span> : null} - <ChevronDown - size={10} - className={`relative top-px ml-1 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`} - /> - </button> - ) : ( - <> - <span className="font-medium">{label}</span> - {detail ? <span> {detail}</span> : null} - {isStreaming ? <span>...</span> : null} - </> - )} - </div> - </div> - {isOpen && hasItems && ( - <ul className="mt-2 ml-[14px] flex flex-col gap-1 text-sm font-serif text-gray-500"> - {items!.map((item, idx) => { - const label = [item.caseName, item.citation] - .filter(Boolean) - .join(", "); - const primary = label || item.url || "Unknown case"; - const searchText = item.query - ? `Searched for "${item.query}" in ${primary}${ - typeof item.totalMatches === "number" - ? ` (${item.totalMatches} ${ - item.totalMatches === 1 - ? "match" - : "matches" - })` - : "" - }` - : null; - return ( - <li key={idx}> - <div - className={ - item.hasError ? "text-red-500" : "" - } - > - {item.url ? ( - <a - href={item.url} - target="_blank" - rel="noreferrer" - className="hover:text-gray-700 hover:underline underline-offset-2" - > - {searchText ?? primary} - </a> - ) : searchText ? ( - <span>{searchText}</span> - ) : ( - <span>{primary}</span> - )} - </div> - </li> - ); - })} - </ul> - )} - </div> - ); -} - -function DocEditedBlock({ - filename, - showConnector, - isStreaming, - hasError, -}: { - filename: string; - showConnector?: boolean; - isStreaming?: boolean; - hasError?: boolean; -}) { - return ( - <div className="flex items-start text-sm font-serif text-gray-500 relative"> - {showConnector && ( - <div className="absolute left-0 top-0 bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - {isStreaming ? ( - <div className="mt-2 w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - ) : hasError ? ( - <div className="mt-2 w-1.5 h-1.5 rounded-full bg-red-500 shrink-0" /> - ) : ( - <div className="mt-2 w-1.5 h-1.5 rounded-full bg-green-400 shrink-0" /> - )} - <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> - <span className="font-medium"> - {isStreaming - ? "Editing" - : hasError - ? "Edit failed" - : "Edited"} - </span>{" "} - <span>{isStreaming ? `${filename}...` : filename}</span> - </div> - </div> - ); -} - -// --------------------------------------------------------------------------- -// Citation preprocessing -// --------------------------------------------------------------------------- - -function preprocessCitations( - text: string, - annotations: CitationAnnotation[], - citationsList: CitationAnnotation[], -): string { - // Replace [N] or [N, M, ...] inline markers with internal §idx§ tokens backed by annotations - return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr, offset) => { - const refs = (refsStr as string) - .split(",") - .map((s: string) => parseInt(s.trim(), 10)); - const tokens = refs.flatMap((ref: number) => { - const ann = annotations.find((a) => a.ref === ref); - if (!ann) return []; - const idx = citationsList.length; - citationsList.push(ann); - return [`\`§${idx}§\`\u200B`]; - }); - return tokens.length > 0 ? tokens.join("") : full; - }); -} - -// --------------------------------------------------------------------------- -// Markdown renderer (shared config) -// --------------------------------------------------------------------------- - -function internalCaseHref( - value: string | number | null | undefined, -): string | null { - if (typeof value === "number") return `us-case-${value}`; - if (!value) return null; - const match = value.match(/^us-case-(\d+)$/); - return match ? `us-case-${match[1]}` : null; -} - -function MarkdownContent({ - text, - citationsList, - caseCitations, - caseOpinions, - onCitationClick, - onCaseClick, - divRef, -}: { - text: string; - citationsList: CitationAnnotation[]; - caseCitations: Map< - string, - Extract<AssistantEvent, { type: "case_citation" }> - >; - caseOpinions: Map< - number, - Extract<AssistantEvent, { type: "case_opinions" }>["case"] - >; - onCitationClick?: (c: CitationAnnotation) => void; - onCaseClick?: ( - c: Extract<AssistantEvent, { type: "case_citation" }>, - ) => void; - divRef?: React.RefObject<HTMLDivElement | null>; -}) { - function findCaseCitation(href: string) { - return caseCitations.get(internalCaseHref(href) ?? ""); - } - - return ( - <div - ref={divRef} - className="text-gray-900 mb-4 text-base prose prose-sm max-w-none font-serif" - > - <ReactMarkdown - remarkPlugins={[ - [remarkMath, { singleDollarTextMath: false }], - remarkGfm, - ]} - rehypePlugins={[rehypeKatex]} - urlTransform={(url) => - /^us-case-\d+$/.test(url) ? url : defaultUrlTransform(url) - } - components={{ - table: ({ node, ...props }) => ( - <div className="overflow-x-auto my-4 rounded-lg"> - <table - className="min-w-full divide-y divide-gray-300 overflow-hidden" - {...props} - /> - </div> - ), - thead: ({ node, ...props }) => ( - <thead className="bg-gray-100" {...props} /> - ), - tbody: ({ node, ...props }) => ( - <tbody - className="divide-y divide-gray-200" - {...props} - /> - ), - tr: ({ node, ...props }) => <tr {...props} />, - th: ({ node, ...props }) => ( - <th - className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - {...props} - /> - ), - td: ({ node, ...props }) => ( - <td - className="whitespace-normal px-3 py-4 text-sm text-gray-900" - {...props} - /> - ), - h1: ({ node, ...props }) => ( - <h1 - className="mt-6 mb-4 text-3xl font-serif font-semibold" - {...props} - /> - ), - h2: ({ node, ...props }) => ( - <h2 - className="mt-5 mb-3 text-2xl font-serif font-semibold" - {...props} - /> - ), - h3: ({ node, ...props }) => ( - <h3 - className="text-xl font-semibold mt-4 mb-2" - {...props} - /> - ), - h4: ({ node, ...props }) => ( - <h4 - className="text-lg font-semibold mt-4 mb-2" - {...props} - /> - ), - p: ({ node, ...props }) => { - const parent = (node as any)?.parent; - if (parent?.type === "listItem") { - return ( - <p - className="inline leading-7 m-0" - {...props} - /> - ); - } - return <p className="mb-4 leading-7" {...props} />; - }, - ul: ({ node, ...props }) => ( - <ul - className="list-disc list-outside mb-4 pl-6" - {...props} - /> - ), - ol: ({ node, ...props }) => ( - <ol - className="list-decimal list-outside mb-4 pl-6" - {...props} - /> - ), - li: ({ node, ...props }) => ( - <li className="mb-2 leading-7" {...props} /> - ), - strong: ({ node, ...props }) => ( - <strong className="font-semibold" {...props} /> - ), - em: ({ node, ...props }) => ( - <em className="italic" {...props} /> - ), - code: ({ node, children, ...props }) => { - const text = String(children); - const citMatch = text.match(/^§(\d+)§$/); - if (citMatch) { - const idx = parseInt(citMatch[1]); - const annotation = citationsList[idx]; - if (annotation) { - const tooltipText = `${formatCitationPage(annotation)}: "${displayCitationQuote(annotation)}"`; - return ( - <button - onClick={() => - onCitationClick?.(annotation) - } - data-citation-ref={annotation.ref} - className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`} - title={tooltipText} - > - {annotation.ref} - </button> - ); - } - } - return ( - <code - className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-serif" - {...props} - > - {children} - </code> - ); - }, - blockquote: ({ node, ...props }) => ( - <blockquote - className="border-l-4 border-gray-300 pl-4 italic my-4" - {...props} - /> - ), - a: ({ node, href, children, ...props }) => { - if (href) { - const isInternalCaseHref = !!internalCaseHref(href); - const citation = findCaseCitation(href); - if (citation && onCaseClick) { - return ( - <button - type="button" - onClick={() => - onCaseClick({ - ...citation, - case: - citation.cluster_id !== null - ? caseOpinions.get( - citation.cluster_id, - ) - : undefined, - }) - } - className="text-left text-blue-600 hover:text-blue-700 underline" - > - {children} - </button> - ); - } - if (citation) { - return ( - <a - href={citation.url} - className="text-blue-600 hover:text-blue-700 underline" - target="_blank" - rel="noopener noreferrer" - > - {children} - </a> - ); - } - if (isInternalCaseHref) { - return ( - <span className="text-blue-600 underline"> - {children} - </span> - ); - } - return ( - <a - href={href} - className="text-blue-600 hover:text-blue-700 underline" - target="_blank" - rel="noopener noreferrer" - {...props} - > - {children} - </a> - ); - } - return ( - <a - href={href} - className="text-blue-600 hover:text-blue-700 underline" - target="_blank" - rel="noopener noreferrer" - {...props} - > - {children} - </a> - ); - }, - hr: ({ node, ...props }) => ( - <hr className="my-6 border-gray-200" {...props} /> - ), - }} - > - {text} - </ReactMarkdown> - </div> - ); -} - -// --------------------------------------------------------------------------- -// Citations block -// --------------------------------------------------------------------------- - -type CitationSourceRow = { - key: string; - label: string; - source: CitationAnnotation; - entries: { annotation: CitationAnnotation; index: number }[]; -}; - -function citationSourceKey(annotation: CitationAnnotation): string { - if (annotation.kind === "case") { - return `case:${annotation.cluster_id}`; - } - return `document:${annotation.document_id}`; -} - -function citationSourceLabel(annotation: CitationAnnotation): string { - if (annotation.kind === "case") { - const caseName = annotation.case_name?.trim(); - const citation = annotation.citation?.trim(); - if (caseName && citation) return `${caseName}, ${citation}`; - return caseName || citation || `Case ${annotation.cluster_id}`; - } - return annotation.filename; -} - -function documentExtension(filename: string): string { - return filename.split(".").pop()?.toLowerCase() ?? ""; -} - -function CitationSourceIcon({ - annotation, -}: { - annotation: CitationAnnotation; -}) { - if (annotation.kind === "case") { - return <Scale className="h-3.5 w-3.5 text-slate-600" />; - } - const ext = documentExtension(annotation.filename); - if (ext === "pdf") return <File className="h-3.5 w-3.5 text-red-500" />; - return <FileText className="h-3.5 w-3.5 text-blue-500" />; -} - -function buildCitationSourceRows( - citations: CitationAnnotation[], -): CitationSourceRow[] { - const rows = new Map<string, CitationSourceRow>(); - citations.forEach((annotation, index) => { - const key = citationSourceKey(annotation); - const existing = rows.get(key); - if (existing) { - existing.entries.push({ annotation, index }); - return; - } - rows.set(key, { - key, - label: citationSourceLabel(annotation), - source: annotation, - entries: [{ annotation, index }], - }); - }); - return Array.from(rows.values()); -} - -function escapeHtmlText(value: string): string { - return value - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function ensureTerminalPeriod(value: string): string { - return /[.!?]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`; -} - -function buildCitationAppendix(citations: CitationAnnotation[]) { - if (citations.length === 0) return { html: "", text: "" }; - let previousSourceKey: string | null = null; - const entries = citations.map((annotation) => { - const sourceKey = citationSourceKey(annotation); - const label = - sourceKey === previousSourceKey - ? "Id." - : citationSourceLabel(annotation); - previousSourceKey = sourceKey; - return { - number: annotation.ref, - label, - quote: displayCitationQuote(annotation).trim(), - }; - }); - const textLines = [ - "", - "Citations", - ...entries.map((entry) => { - const quote = entry.quote ? ` "${entry.quote}"` : ""; - return `${entry.number} ${ensureTerminalPeriod(entry.label)}${quote}`; - }), - ]; - const html = [ - `<section class="copied-citations">`, - `<h3>Citations</h3>`, - ...entries.map((entry) => { - const label = escapeHtmlText(ensureTerminalPeriod(entry.label)); - const quote = entry.quote - ? ` "${escapeHtmlText(entry.quote)}"` - : ""; - return `<p><sup>${entry.number}</sup> ${label}${quote}</p>`; - }), - `</section>`, - ].join(""); - return { html, text: textLines.join("\n") }; -} - -function CitationsBlock({ - citationsList, - onCitationClick, - onOpenSource, - canOpenSource, - showWhenEmpty = false, - isLoading = false, -}: { - citationsList: CitationAnnotation[]; - onCitationClick?: (citation: CitationAnnotation) => void; - onOpenSource?: (citation: CitationAnnotation) => void; - canOpenSource?: (citation: CitationAnnotation) => boolean; - showWhenEmpty?: boolean; - isLoading?: boolean; -}) { - const rows = buildCitationSourceRows(citationsList); - if (rows.length === 0 && !showWhenEmpty) return null; - - return ( - <div className="mt-2 mb-3"> - <div className={`overflow-hidden ${RESPONSE_GLASS_SURFACE}`}> - <div className="flex items-center justify-between gap-3 bg-white/25 px-3 py-2"> - <h3 className="text-base font-serif text-gray-900"> - Citations - </h3> - {isLoading && ( - <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" /> - )} - </div> - <div> - {rows.map((row) => { - const sourceIsClickable = - !!onOpenSource && - (canOpenSource?.(row.source) ?? true); - return ( - <div - key={row.key} - className="flex items-center gap-3 px-3 py-3" - > - <button - type="button" - onClick={() => onOpenSource?.(row.source)} - disabled={!sourceIsClickable} - className="flex min-w-0 flex-1 items-center gap-2 rounded-lg text-left text-sm font-serif text-gray-700 transition-colors enabled:hover:text-gray-950 disabled:cursor-default" - > - <CitationSourceIcon - annotation={row.source} - /> - <span className="truncate"> - {row.label} - </span> - </button> - <div className="flex shrink-0 flex-wrap justify-end gap-1"> - {row.entries.map( - ({ annotation, index }) => ( - <button - key={`${row.key}:${index}`} - type="button" - onClick={() => - onCitationClick?.( - annotation, - ) - } - className={ - RESPONSE_GLASS_ANNOTATION - } - title={`${formatCitationPage(annotation)}: "${displayCitationQuote(annotation)}"`} - > - {annotation.ref} - </button> - ), - )} - </div> - </div> - ); - })} - </div> - </div> - </div> - ); -} - -// --------------------------------------------------------------------------- -// Stream smoothing -// --------------------------------------------------------------------------- - -/** - * Hide jitter from arrival of streamed text chunks by revealing characters at - * a smooth, rate-paced clip rather than rendering every chunk verbatim. - * - * Returns a prefix of `text` whose length grows over time toward the full - * length. When `active` is false (stream ended, message replayed from - * history, etc.), snaps to the full text immediately. - * - * Rate adapts to backlog: small backlogs reveal at a 40 cps floor; large - * backlogs catch up within ~0.4s, so the smoothing never lags noticeably - * behind the server. - */ -function useSmoothedReveal(text: string, active: boolean): string { - const [revealedInt, setRevealedInt] = useState(text.length); - const revealedFloat = useRef<number>(text.length); - - useEffect(() => { - if (!active) { - revealedFloat.current = text.length; - setRevealedInt(text.length); - return; - } - - // Defensive clamp in case the text was edited / replaced shorter. - if (revealedFloat.current > text.length) { - revealedFloat.current = text.length; - setRevealedInt(text.length); - } - - let lastTick = performance.now(); - let raf = 0; - let cancelled = false; - - const step = (now: number) => { - if (cancelled) return; - const dt = Math.max(0, (now - lastTick) / 1000); - lastTick = now; - const target = text.length; - const prev = revealedFloat.current; - if (prev < target) { - const backlog = target - prev; - const cps = Math.max(40, backlog / 0.4); - const next = Math.min(target, prev + cps * dt); - revealedFloat.current = next; - const nextInt = Math.floor(next); - setRevealedInt((cur) => (cur === nextInt ? cur : nextInt)); - } - raf = requestAnimationFrame(step); - }; - - raf = requestAnimationFrame(step); - return () => { - cancelled = true; - cancelAnimationFrame(raf); - }; - }, [text.length, active]); - - return text.slice(0, Math.min(revealedInt, text.length)); -} - -// --------------------------------------------------------------------------- -// Main component -// --------------------------------------------------------------------------- + AskInputsBlock, + CourtListenerBlock, + DocCreatedBlock, + DocDownloadBlock, + DocEditedBlock, + DocFindBlock, + DocReadBlock, + DocReplicatedBlock, + EventBlock, + ReasoningBlock, + WorkflowAppliedBlock, + type CourtListenerBlockItem, +} from "./message/EventBlocks"; interface Props { - content: string; events?: AssistantEvent[]; isStreaming?: boolean; isError?: boolean; /** Human-readable error text rendered alongside the red Mike icon. */ errorMessage?: string; - annotations?: CitationAnnotation[]; + citations?: Citation[]; citationStatus?: "started" | "partial" | "final"; - onCitationClick?: (citation: CitationAnnotation) => void; - onOpenCitationSource?: (citation: CitationAnnotation) => void; + onCitationClick?: (citation: Citation) => void; + onOpenCitationSource?: (citation: Citation) => void; onCaseClick?: ( citation: Extract<AssistantEvent, { type: "case_citation" }>, ) => void; minHeight?: string; onWorkflowClick?: (workflowId: string) => void; - onEditViewClick?: (ann: EditAnnotation, filename: string) => void; + onEditViewClick?: ( + ann: EditAnnotation, + filename: string, + changeNumber?: number, + ) => void; /** * Opens the editor panel for a document without auto-highlighting any * specific edit. Used by the download card click — opening a doc to @@ -1636,12 +99,11 @@ interface Props { } export function AssistantMessage({ - content: _content, events, isStreaming = false, isError = false, errorMessage, - annotations = [], + citations = [], citationStatus, onCitationClick, onOpenCitationSource, @@ -1657,7 +119,6 @@ export function AssistantMessage({ isEditReloading, resolvedEditStatuses, }: Props) { - const messageKey = useId(); const contentDivRef = useRef<HTMLDivElement | null>(null); const [isCopied, setIsCopied] = useState(false); // Per-document override of the download URL, set as Accept/Reject resolves @@ -1704,6 +165,7 @@ export function AssistantMessage({ const isRenderableEvent = (event: AssistantEvent) => event.type !== "error" && + event.type !== "ask_inputs_response" && event.type !== "case_citation" && event.type !== "case_opinions"; @@ -1742,9 +204,9 @@ export function AssistantMessage({ ); // Pre-process citations for all content events. Each [N] marker resolves - // to exactly one annotation (models are instructed to use shared refs + // to exactly one citation (models are instructed to use shared refs // only for cross-page continuations via the [[PAGE_BREAK]] sentinel). - const citationsList: CitationAnnotation[] = []; + const inlineCitationTargets: Citation[] = []; const caseCitations = new Map< string, Extract<AssistantEvent, { type: "case_citation" }> @@ -1767,14 +229,14 @@ export function AssistantMessage({ event.type === "content" ? preprocessCitations( i === lastContentIdx ? smoothedLastText : event.text, - annotations, - citationsList, + citations, + inlineCitationTargets, ) : "", ); } } - const handleOpenCitationSource = (citation: CitationAnnotation) => { + const handleOpenCitationSource = (citation: Citation) => { if (onOpenCitationSource) { onOpenCitationSource(citation); return; @@ -1787,12 +249,11 @@ export function AssistantMessage({ versionNumber: citation.version_number ?? null, }); }; - const canOpenCitationSource = (citation: CitationAnnotation) => + const canOpenCitationSource = (citation: Citation) => !!onOpenCitationSource || (citation.kind !== "case" && !!onOpenDocument); - const citationBlockList = citationStatus ? annotations : citationsList; const showCitationBlock = - !!citationStatus || (!isStreaming && citationsList.length > 0); + !!citationStatus || (!isStreaming && citations.length > 0); const handleCopy = async () => { try { let html = ""; @@ -1811,7 +272,7 @@ export function AssistantMessage({ html = clone.innerHTML; plainText = clone.textContent || ""; } - const appendix = buildCitationAppendix(citationBlockList); + const appendix = buildCitationAppendix(citations); html += appendix.html; plainText += appendix.text; const item = new ClipboardItem({ @@ -1867,6 +328,23 @@ export function AssistantMessage({ return false; }; + const askInputsResponseFor = (askInputsIdx: number) => { + if (!events) return undefined; + for (let i = askInputsIdx + 1; i < events.length; i++) { + const candidate = events[i]; + if (candidate.type === "ask_inputs") return undefined; + if (candidate.type === "ask_inputs_response") return candidate; + } + return undefined; + }; + + const hasPendingAskInput = (group: Extract<EventGroup, { kind: "pre" }>) => + group.events.some( + (event, index) => + event.type === "ask_inputs" && + !askInputsResponseFor(group.indices[index]), + ); + const renderEvent = ( event: AssistantEvent, i: number, @@ -1877,23 +355,6 @@ export function AssistantMessage({ const showConnector = nextEvent !== undefined && nextEvent.type !== "content"; - if (event.type === "content") { - const isLastContent = globalIdx === lastContentIdx; - const processed = processedTexts[globalIdx]; - return ( - <div key={globalIdx}> - <MarkdownContent - text={processed} - citationsList={citationsList} - caseCitations={caseCitations} - caseOpinions={caseOpinions} - onCitationClick={onCitationClick} - onCaseClick={onCaseClick} - divRef={isLastContent ? contentDivRef : undefined} - /> - </div> - ); - } if (event.type === "reasoning") { return ( <ReasoningBlock @@ -1906,32 +367,26 @@ export function AssistantMessage({ } if (event.type === "tool_call_start") { return ( - <div + <EventBlock key={globalIdx} - className="flex items-center text-sm font-serif text-gray-500 relative" + showConnector={showConnector} + isStreaming > - {showConnector && ( - <div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - <div className="w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - <span className="font-medium ml-2"> + <span className="font-medium"> {toolCallLabel(event.name)} </span> - </div> + </EventBlock> ); } if (event.type === "thinking") { return ( - <div + <EventBlock key={globalIdx} - className="flex items-center text-sm font-serif text-gray-500 relative" + showConnector={showConnector} + isStreaming > - {showConnector && ( - <div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> - )} - <div className="w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> - <span className="ml-2">Thinking...</span> - </div> + <span>Thinking...</span> + </EventBlock> ); } if (event.type === "mcp_tool_call") { @@ -1940,37 +395,25 @@ export function AssistantMessage({ ? `${event.connector_name}: ${event.tool_name}` : toolCallLabel(event.openai_tool_name); return ( - <div + <EventBlock key={globalIdx} - className="flex items-start text-sm font-serif text-gray-500 relative" + showConnector={showConnector} + isStreaming={event.isStreaming} + dotColor={isError ? "red" : "gray"} > - {showConnector && ( - <div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> + <span className="font-medium"> + {event.isStreaming ? "Using connector..." : label} + </span> + {isError && event.error && ( + <p className="mt-0.5 text-xs text-red-600"> + {event.error} + </p> )} - <div - className={ - event.isStreaming - ? "mt-[7px] h-1.5 w-1.5 shrink-0 animate-spin rounded-full border border-gray-400 border-t-transparent" - : isError - ? "mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" - : "mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-gray-400" - } - /> - <div className="ml-2 min-w-0"> - <span className="font-medium"> - {event.isStreaming ? "Using connector..." : label} - </span> - {isError && event.error && ( - <p className="mt-0.5 text-xs text-red-600"> - {event.error} - </p> - )} - </div> - </div> + </EventBlock> ); } if (event.type === "doc_read") { - const ann = annotations.find( + const ann = citations.find( (a) => a.kind !== "case" && a.filename === event.filename, ); return ( @@ -2049,6 +492,17 @@ export function AssistantMessage({ /> ); } + if (event.type === "ask_inputs") { + const response = askInputsResponseFor(globalIdx); + return ( + <AskInputsBlock + key={globalIdx} + event={event} + response={response} + showConnector={showConnector} + /> + ); + } if (event.type === "courtlistener_search_case_law") { const count = event.result_count ?? 0; const detail = event.isStreaming @@ -2286,7 +740,9 @@ export function AssistantMessage({ <div key={`c-${g.index}`}> <MarkdownContent text={processedTexts[g.index]} - citationsList={citationsList} + inlineCitationTargets={ + inlineCitationTargets + } caseCitations={caseCitations} caseOpinions={caseOpinions} onCitationClick={onCitationClick} @@ -2301,17 +757,24 @@ export function AssistantMessage({ ); } const subsequentContent = hasContentAfter(gIdx); - const wrapperIsStreaming = g.events.some( - (event) => - "isStreaming" in event && - !!event.isStreaming, - ); + const pendingAskInput = hasPendingAskInput(g); + const wrapperIsStreaming = + g.events.some( + (event) => + "isStreaming" in event && + !!event.isStreaming, + ) || pendingAskInput; return ( <PreResponseWrapper key={`p-${g.indices[0]}`} stepCount={g.events.length} - shouldMinimize={subsequentContent} + shouldMinimize={ + pendingAskInput + ? false + : subsequentContent + } isStreaming={wrapperIsStreaming} + forceOpen={pendingAskInput} > {g.events.map((event, i) => renderEvent( @@ -2363,29 +826,40 @@ export function AssistantMessage({ } } } + let cardIndex = 0; const cards = editedEvents.flatMap((e) => - e.annotations.map((ann) => ( - <EditCard - key={`editcard-${ann.edit_id}`} - annotation={ann} - resolvedStatus={ - resolvedEditStatuses?.[ - ann.edit_id - ] - } - isReloading={ - isEditReloading?.( - ann.edit_id, - ) ?? false - } - onViewClick={(a) => - onEditViewClick?.(a, e.filename) - } - onResolveStart={onEditResolveStart} - onResolved={handleEditResolved} - onError={onEditError} - /> - )), + e.annotations.map((ann) => { + const changeNumber = ++cardIndex; + return ( + <EditCard + key={`editcard-${ann.edit_id}`} + annotation={ann} + changeNumber={changeNumber} + resolvedStatus={ + resolvedEditStatuses?.[ + ann.edit_id + ] + } + isReloading={ + isEditReloading?.( + ann.edit_id, + ) ?? false + } + onViewClick={(a) => + onEditViewClick?.( + a, + e.filename, + changeNumber, + ) + } + onResolveStart={ + onEditResolveStart + } + onResolved={handleEditResolved} + onError={onEditError} + /> + ); + }), ); const resolvedCount = editedEvents.reduce( (acc, e) => @@ -2537,7 +1011,7 @@ export function AssistantMessage({ {showCitationBlock && ( <CitationsBlock - citationsList={citationBlockList} + citations={citations} onCitationClick={onCitationClick} onOpenSource={handleOpenCitationSource} canOpenSource={canOpenCitationSource} @@ -2550,7 +1024,7 @@ export function AssistantMessage({ )} {/* Copy button */} - <div className="flex items-center gap-2 pt-2 pb-4 md:pb-8 font-sans justify-start"> + <div className="flex items-center gap-2 py-2 font-sans justify-start"> {!isStreaming && ( <button className="p-1.5 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-100" diff --git a/frontend/src/app/components/assistant/AssistantSidePanel.tsx b/frontend/src/app/components/assistant/AssistantSidePanel.tsx index 639ab09..eb22b23 100644 --- a/frontend/src/app/components/assistant/AssistantSidePanel.tsx +++ b/frontend/src/app/components/assistant/AssistantSidePanel.tsx @@ -8,16 +8,16 @@ 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 { CaseLawPanel, type CaseTab, } from "./CaseLawPanel"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; // --------------------------------------------------------------------------- // Tab data @@ -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, diff --git a/frontend/src/app/components/assistant/CaseLawPanel.tsx b/frontend/src/app/components/assistant/CaseLawPanel.tsx index 28d8aea..e60e536 100644 --- a/frontend/src/app/components/assistant/CaseLawPanel.tsx +++ b/frontend/src/app/components/assistant/CaseLawPanel.tsx @@ -13,21 +13,21 @@ import { Download, ExternalLink, } from "lucide-react"; -import { MikeIcon } from "@/components/chat/mike-icon"; +import { MikeIcon } from "@/app/components/chat/mike-icon"; 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, } from "@/app/lib/mikeApi"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; export type CaseTab = { kind: "case"; @@ -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} diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index e6ded8e..206ef38 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -11,27 +11,26 @@ 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"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { getModelProvider, isModelAvailable, type ModelProvider, } from "@/app/lib/modelAvailability"; import type { Document, Message } from "../shared/types"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; export interface ChatInputHandle { addDoc: (doc: Document) => void; @@ -97,13 +96,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( return () => observer.disconnect(); }, []); - const handleAddDocFromProject = useCallback((doc: Document) => { - setAttachedDocs((prev) => { - if (prev.some((d) => d.id === doc.id)) return prev; - return [...prev, doc]; - }); - }, []); - const handleAddDocsFromSelector = useCallback( (selectedDocs: Document[]) => { setAttachedDocs((prev) => { @@ -193,18 +185,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> @@ -248,7 +237,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( <div className="flex items-center gap-1"> {!hideAddDocButton && ( <AddDocButton - onSelectDoc={handleAddDocFromProject} onBrowseAll={() => setDocSelectorOpen(true)} selectedDocIds={attachedDocs.map( (d) => d.id, @@ -342,13 +330,16 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( open={workflowModalOpen} onClose={() => setWorkflowModalOpen(false)} onSelect={(wf) => { - setSelectedWorkflow({ id: wf.id, title: wf.title }); + setSelectedWorkflow({ + id: wf.id, + title: wf.metadata.title, + }); setWorkflowModalOpen(false); }} projectName={projectName} projectCmNumber={projectCmNumber} /> - <ApiKeyMissingModal + <ApiKeyMissingPopup open={apiKeyModalProvider !== null} provider={apiKeyModalProvider} onClose={() => setApiKeyModalProvider(null)} diff --git a/frontend/src/app/components/assistant/ChatView.tsx b/frontend/src/app/components/assistant/ChatView.tsx index 5c4a577..f49e7e4 100644 --- a/frontend/src/app/components/assistant/ChatView.tsx +++ b/frontend/src/app/components/assistant/ChatView.tsx @@ -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> diff --git a/frontend/src/app/components/shared/RelevantQuotes.tsx b/frontend/src/app/components/assistant/CitationQuotesHeader.tsx similarity index 87% rename from frontend/src/app/components/shared/RelevantQuotes.tsx rename to frontend/src/app/components/assistant/CitationQuotesHeader.tsx index faf4d63..5858a7f 100644 --- a/frontend/src/app/components/shared/RelevantQuotes.tsx +++ b/frontend/src/app/components/assistant/CitationQuotesHeader.tsx @@ -2,9 +2,9 @@ import { useEffect, useState, type ReactNode } from "react"; import { Minus, RectangleHorizontal, Rows3 } from "lucide-react"; -import { CiteButton } from "@/components/ui/cite-button"; +import { CiteButton } from "@/app/components/ui/cite-button"; -export type RelevantQuoteItem = { +export type CitationQuoteHeaderItem = { id: string; quote: string; eyebrow?: string | null; @@ -13,19 +13,23 @@ export type RelevantQuoteItem = { citationText?: string | null; }; +const QUOTE_GLASS_SURFACE = + "rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl"; +const QUOTE_CARD_SURFACE = "rounded-2xl bg-gray-100"; + interface Props { - quotes: RelevantQuoteItem[]; + quotes: CitationQuoteHeaderItem[]; error?: string | null; isLoading?: boolean; activeQuoteId?: string | null; currentIndex?: number; citationRef?: number; citationText?: string; - onSelect?: (quote: RelevantQuoteItem, index: number) => void; + onSelect?: (quote: CitationQuoteHeaderItem, index: number) => void; onIndexChange?: (index: number) => void; } -export function RelevantQuotes({ +export function CitationQuotesHeader({ quotes, error = null, isLoading = false, @@ -49,23 +53,29 @@ export function RelevantQuotes({ return ( <div className="px-3"> - <div className="rounded-lg border border-gray-200"> + <div className={QUOTE_GLASS_SURFACE}> <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 className="flex items-center gap-1.5 text-xs font-medium text-gray-700"> + <span>Citation</span> + {typeof citationRef === "number" && ( + <span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-gray-200 px-1 text-[9px] font-medium text-gray-600"> + {citationRef} + </span> + )} </p> + </div> + <div className="flex items-center gap-2"> {hasMultipleQuotes && ( <div className="flex items-center gap-1"> + <span className="mr-0.5 text-xs font-medium text-gray-500"> + Quotes + </span> {quotes.map((quote, index) => ( <button key={quote.id} type="button" - onClick={() => - onIndexChange?.(index) - } + 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)]" @@ -77,8 +87,6 @@ export function RelevantQuotes({ ))} </div> )} - </div> - <div className="flex items-center gap-2"> {currentQuote && ( <CiteButton quoteText={currentQuote.quote} @@ -207,7 +215,7 @@ export function RelevantQuotes({ function RelevantQuoteSkeleton() { return ( - <div className="animate-pulse rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5"> + <div className={`animate-pulse px-3 py-2.5 ${QUOTE_CARD_SURFACE}`}> <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" /> @@ -224,7 +232,7 @@ function RelevantQuoteMessage({ tone?: "neutral" | "error"; }) { return ( - <div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5"> + <div className={`px-3 py-2.5 ${QUOTE_CARD_SURFACE}`}> <p className={`font-serif text-sm leading-6 ${ tone === "error" ? "text-red-700" : "text-gray-600" @@ -241,7 +249,7 @@ function QuoteItem({ isActive, onClick, }: { - quote: RelevantQuoteItem; + quote: CitationQuoteHeaderItem; isActive: boolean; onClick: () => void; }) { @@ -249,10 +257,8 @@ function QuoteItem({ <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" + className={`w-full rounded-xl px-3 py-2.5 text-left transition-colors ${ + isActive ? "bg-blue-100/70" : "bg-gray-100 hover:bg-gray-200/70" }`} > <div className="flex flex-col gap-1.5"> diff --git a/frontend/src/app/components/shared/DocPanel.tsx b/frontend/src/app/components/assistant/DocPanel.tsx similarity index 57% rename from frontend/src/app/components/shared/DocPanel.tsx rename to frontend/src/app/components/assistant/DocPanel.tsx index 5a7f8ec..0d1f626 100644 --- a/frontend/src/app/components/shared/DocPanel.tsx +++ b/frontend/src/app/components/assistant/DocPanel.tsx @@ -2,25 +2,29 @@ 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 { supabase } from "@/app/lib/supabase"; +import { PdfView } from "../shared/views/PdfView"; +import { DocxView } from "../shared/views/DocxView"; +import { SpreadsheetView } from "../shared/views/SpreadsheetView"; import { - RelevantQuotes, - type RelevantQuoteItem, -} from "./RelevantQuotes"; + CitationQuotesHeader, + type CitationQuoteHeaderItem, +} from "./CitationQuotesHeader"; +import { TrackedChangeHeader } from "./TrackedChangeHeader"; import { + cleanCitationQuoteText, expandCitationToEntries, formatCitationPage, + formatCitationQuotePage, getDocumentCitationQuotes, -} from "./types"; + isSpreadsheetFilename, +} from "../shared/types"; import type { CitationQuote, - CitationAnnotation, - DocumentCitationAnnotation, + Citation, + DocumentCitation, EditAnnotation, -} from "./types"; +} from "../shared/types"; function isDocxFilename(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase(); @@ -35,10 +39,11 @@ function isDocxFilename(name: string): boolean { */ export type DocPanelMode = | { kind: "document" } - | { kind: "citation"; citation: CitationAnnotation } + | { kind: "citation"; citation: Citation } | { kind: "edit"; edit: EditAnnotation; + changeNumber?: number; /** * True while an accept/reject request for this exact edit is in * flight. Scoped per-edit (not per-document) so sibling edits on @@ -103,12 +108,14 @@ export function DocPanel({ // 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 useSheetView = isSpreadsheetFilename(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 [editFocusKey, setEditFocusKey] = useState(0); const quotes: CitationQuote[] | undefined = useMemo(() => { if (mode.kind !== "citation") return undefined; @@ -118,7 +125,7 @@ export function DocPanel({ const selectedQuote = getDocumentCitationQuotes(mode.citation)[selectedIndex]; if (!selectedQuote) return []; - const documentCitation = mode.citation as DocumentCitationAnnotation; + const documentCitation = mode.citation as DocumentCitation; return expandCitationToEntries({ ...documentCitation, page: selectedQuote.page, @@ -127,6 +134,20 @@ export function DocPanel({ }); }, [activeCitationQuoteId, citationQuoteId, mode]); + // Cell locator(s) for the selected quote, used to highlight the cited cell + // when the document is a spreadsheet. + const highlightCells = 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 || (!selectedQuote.cell && !selectedQuote.sheet)) + return []; + return [{ sheet: selectedQuote.sheet, cell: selectedQuote.cell }]; + }, [activeCitationQuoteId, mode]); + useEffect(() => { setActiveCitationQuoteId(citationQuoteId); }, [citationQuoteId]); @@ -143,13 +164,13 @@ export function DocPanel({ const highlightEdit = useMemo(() => { if (mode.kind !== "edit") return null; return { - key: `${mode.edit.edit_id}`, + key: `${mode.edit.edit_id}:${editFocusKey}`, 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]); + }, [editFocusKey, mode]); return ( <div className="flex h-full flex-col"> @@ -170,7 +191,17 @@ export function DocPanel({ /> )} - {mode.kind === "edit" && <TrackedChangeHeader mode={mode} />} + {mode.kind === "edit" && ( + <TrackedChangeHeader + edit={mode.edit} + changeNumber={mode.changeNumber} + isEditReloading={mode.isEditReloading} + onResolveStart={mode.onResolveStart} + onResolved={mode.onResolved} + onError={mode.onError} + onHighlight={() => setEditFocusKey((current) => current + 1)} + /> + )} <div className="flex flex-1 min-h-0 flex-col px-3 py-3"> {useDocxView ? ( @@ -185,8 +216,14 @@ export function DocPanel({ initialScrollTop={initialScrollTop ?? null} onScrollChange={onScrollChange} /> + ) : useSheetView ? ( + <SpreadsheetView + documentId={documentId} + versionId={versionId} + highlightCells={highlightCells} + /> ) : ( - <DocView + <PdfView doc={{ document_id: documentId, version_id: versionId, @@ -246,17 +283,13 @@ function DocumentTitleRow({ ); } -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; + citation: Citation; filename: string; activeQuoteId: string | null; onQuoteSelect: (quoteId: string) => void; @@ -264,13 +297,17 @@ function RelevantQuoteSection({ const citationQuotes = getDocumentCitationQuotes(citation); const pagesLabel = formatCitationPage(citation); const citationText = [filename, pagesLabel].filter(Boolean).join(", "); - const relevantQuotes: RelevantQuoteItem[] = citationQuotes.map( + const relevantQuotes: CitationQuoteHeaderItem[] = citationQuotes.map( (quote, index) => { - const pageLabel = `Page ${quote.page}`; + const pageLabel = formatCitationQuotePage( + citation, + quote.page, + quote, + ); return { id: `document:${citation.ref}:${index}`, - quote: quote.quote.replaceAll("[[PAGE_BREAK]]", "..."), - inlineDetail: pageLabel, + quote: cleanCitationQuoteText(citation, quote.quote), + inlineDetail: pageLabel || null, citationText: [filename, pageLabel].filter(Boolean).join(", "), }; }, @@ -281,7 +318,7 @@ function RelevantQuoteSection({ ); return ( - <RelevantQuotes + <CitationQuotesHeader quotes={relevantQuotes} activeQuoteId={activeQuoteId} currentIndex={currentIndex} @@ -296,200 +333,6 @@ function RelevantQuoteSection({ ); } -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 // --------------------------------------------------------------------------- diff --git a/frontend/src/app/components/assistant/EditCard.tsx b/frontend/src/app/components/assistant/EditCard.tsx index ddee3ab..9fc8be9 100644 --- a/frontend/src/app/components/assistant/EditCard.tsx +++ b/frontend/src/app/components/assistant/EditCard.tsx @@ -1,7 +1,8 @@ "use client"; import { useState } from "react"; -import { supabase } from "@/lib/supabase"; +import { supabase } from "@/app/lib/supabase"; +import { PillButton } from "@/app/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> diff --git a/frontend/src/app/components/assistant/InitialView.tsx b/frontend/src/app/components/assistant/InitialView.tsx index 93ca60f..07a0794 100644 --- a/frontend/src/app/components/assistant/InitialView.tsx +++ b/frontend/src/app/components/assistant/InitialView.tsx @@ -1,9 +1,9 @@ "use client"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { useAuth } from "@/contexts/AuthContext"; -import { useUserProfile } from "@/contexts/UserProfileContext"; -import { MikeIcon } from "@/components/chat/mike-icon"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import { MikeIcon } from "@/app/components/chat/mike-icon"; import { ChatInput } from "./ChatInput"; import { SelectAssistantProjectModal } from "./SelectAssistantProjectModal"; import type { Message } from "../shared/types"; diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index e51d343..d52fca2 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -9,7 +9,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +} from "@/app/components/ui/dropdown-menu"; import { isModelAvailable } from "@/app/lib/modelAvailability"; import type { ApiKeyState } from "@/app/lib/mikeApi"; diff --git a/frontend/src/app/components/shared/PreResponseWrapper.tsx b/frontend/src/app/components/assistant/PreResponseWrapper.tsx similarity index 94% rename from frontend/src/app/components/shared/PreResponseWrapper.tsx rename to frontend/src/app/components/assistant/PreResponseWrapper.tsx index f5a26a3..77f3255 100644 --- a/frontend/src/app/components/shared/PreResponseWrapper.tsx +++ b/frontend/src/app/components/assistant/PreResponseWrapper.tsx @@ -9,6 +9,7 @@ export function PreResponseWrapper({ shouldMinimize, isStreaming, compact = false, + forceOpen = false, }: { children: React.ReactNode; stepCount: number; @@ -16,6 +17,7 @@ export function PreResponseWrapper({ isStreaming: boolean; /** Tighter typography + child gap for narrow side panels (e.g. TR chat). */ compact?: boolean; + forceOpen?: boolean; }) { const [userToggled, setUserToggled] = useState(false); const [isOpen, setIsOpen] = useState(!shouldMinimize); @@ -26,10 +28,14 @@ export function PreResponseWrapper({ const hasMinimizedRef = useRef(shouldMinimize); useEffect(() => { + if (forceOpen) { + setIsOpen(true); + return; + } if (shouldMinimize) hasMinimizedRef.current = true; if (userToggled) return; setIsOpen(!shouldMinimize && !hasMinimizedRef.current); - }, [shouldMinimize, userToggled]); + }, [forceOpen, shouldMinimize, userToggled]); const stepWord = `step${stepCount === 1 ? "" : "s"}`; const label = isStreaming diff --git a/frontend/src/app/components/assistant/SelectAssistantProjectModal.tsx b/frontend/src/app/components/assistant/SelectAssistantProjectModal.tsx index 0c8bd94..b6ef660 100644 --- a/frontend/src/app/components/assistant/SelectAssistantProjectModal.tsx +++ b/frontend/src/app/components/assistant/SelectAssistantProjectModal.tsx @@ -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> + /> ); } diff --git a/frontend/src/app/components/assistant/TrackedChangeHeader.tsx b/frontend/src/app/components/assistant/TrackedChangeHeader.tsx new file mode 100644 index 0000000..2e28445 --- /dev/null +++ b/frontend/src/app/components/assistant/TrackedChangeHeader.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { supabase } from "@/app/lib/supabase"; +import { PillButton } from "@/app/components/ui/pill-button"; +import { applyOptimisticResolution } from "./EditCard"; +import type { EditAnnotation } from "../shared/types"; + +const PANEL_GLASS_SURFACE = + "rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl"; +const PANEL_CARD_SURFACE = "rounded-lg bg-gray-100"; + +type ResolveArgs = { + editId: string; + documentId: string; + verb: "accept" | "reject"; +}; + +type ResolvedArgs = { + editId: string; + documentId: string; + status: "accepted" | "rejected"; + versionId: string | null; + downloadUrl: string | null; +}; + +type ErrorArgs = { + editId: string; + documentId: string; + versionId: string | null; + message: string; +}; + +export function TrackedChangeHeader({ + edit, + changeNumber, + isEditReloading, + onResolveStart, + onResolved, + onError, + onHighlight, +}: { + edit: EditAnnotation; + changeNumber?: number; + isEditReloading?: boolean; + onResolveStart?: (args: ResolveArgs) => void; + onResolved?: (args: ResolvedArgs) => void; + onError?: (args: ErrorArgs) => void; + onHighlight?: () => void; +}) { + return ( + <div className="px-3 pb-3"> + <div className={`${PANEL_GLASS_SURFACE} px-2 py-2`}> + <div className="mb-1 flex items-center gap-2"> + <p className="text-xs font-medium text-gray-700"> + Tracked Change + {changeNumber !== undefined ? ` ${changeNumber}` : ""} + </p> + <div className="ml-auto flex shrink-0 items-center gap-2"> + <EditResolveButtons + edit={edit} + isReloading={isEditReloading} + onResolveStart={onResolveStart} + onResolved={onResolved} + onError={onError} + /> + </div> + </div> + {edit.reason && ( + <p className="mb-3 text-xs text-gray-500">{edit.reason}</p> + )} + <div + className={`w-full px-3 py-2.5 text-left transition-colors ${PANEL_CARD_SURFACE} ${ + onHighlight ? "cursor-pointer hover:bg-gray-200/70" : "" + }`} + role={onHighlight ? "button" : undefined} + tabIndex={onHighlight ? 0 : undefined} + onClick={onHighlight} + onKeyDown={(event) => { + if (!onHighlight) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onHighlight(); + }} + > + <div className="font-serif text-sm leading-relaxed"> + {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> + </div> + ); +} + +function EditResolveButtons({ + edit, + isReloading, + onResolveStart, + onResolved, + onError, +}: { + edit: EditAnnotation; + isReloading?: boolean; + onResolveStart?: (args: ResolveArgs) => void; + onResolved?: (args: ResolvedArgs) => void; + onError?: (args: ErrorArgs) => void; +}) { + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState<"pending" | "accepted" | "rejected">( + edit.status, + ); + + 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, + }); + let revert: (() => void) | null = null; + try { + revert = applyOptimisticResolution(edit, verb); + } catch (e) { + console.error( + "[TrackedChangeHeader] 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("[TrackedChangeHeader] resolve failed", e); + try { + revert?.(); + } catch (revertErr) { + console.error( + "[TrackedChangeHeader] 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"> + <PillButton + tone="black" + size="sm" + onClick={() => handle("accept")} + disabled={inFlight || resolved} + > + {status === "accepted" ? "Accepted" : "Accept"} + </PillButton> + <PillButton + tone="white" + size="sm" + onClick={() => handle("reject")} + disabled={inFlight || resolved} + > + {status === "rejected" ? "Rejected" : "Reject"} + </PillButton> + </div> + ); +} diff --git a/frontend/src/app/components/assistant/UserMessage.tsx b/frontend/src/app/components/assistant/UserMessage.tsx index 5f312f7..f6381df 100644 --- a/frontend/src/app/components/assistant/UserMessage.tsx +++ b/frontend/src/app/components/assistant/UserMessage.tsx @@ -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> diff --git a/frontend/src/app/components/assistant/message/CitationSources.tsx b/frontend/src/app/components/assistant/message/CitationSources.tsx new file mode 100644 index 0000000..dd6ac4f --- /dev/null +++ b/frontend/src/app/components/assistant/message/CitationSources.tsx @@ -0,0 +1,206 @@ +import { Loader2, Scale } from "lucide-react"; +import { FileTypeIcon } from "../../shared/FileTypeIcon"; +import { displayCitationQuote, formatCitationPage } from "../../shared/types"; +import type { Citation } from "../../shared/types"; +import { RESPONSE_GLASS_ANNOTATION, RESPONSE_GLASS_SURFACE } from "./messageStyles"; + +type CitationSourceRow = { + key: string; + label: string; + source: Citation; + entries: { annotation: Citation; index: number }[]; +}; + +function citationSourceKey(annotation: Citation): string { + if (annotation.kind === "case") { + return `case:${annotation.cluster_id}`; + } + return `document:${annotation.document_id}`; +} + +function citationSourceLabel(annotation: Citation): string { + if (annotation.kind === "case") { + const caseName = annotation.case_name?.trim(); + const citation = annotation.citation?.trim(); + if (caseName && citation) return `${caseName}, ${citation}`; + return caseName || citation || `Case ${annotation.cluster_id}`; + } + return annotation.filename; +} + +export function citationTooltip(annotation: Citation): string { + const locator = formatCitationPage(annotation); + const quote = displayCitationQuote(annotation); + return locator ? `${locator}: "${quote}"` : `"${quote}"`; +} + +function CitationSourceIcon({ + annotation, +}: { + annotation: Citation; +}) { + if (annotation.kind === "case") { + return <Scale className="h-3.5 w-3.5 text-slate-600" />; + } + return ( + <FileTypeIcon fileType={annotation.filename} className="h-3.5 w-3.5" /> + ); +} + +function buildCitationSourceRows( + citations: Citation[], +): CitationSourceRow[] { + const rows = new Map<string, CitationSourceRow>(); + citations.forEach((annotation, index) => { + const key = citationSourceKey(annotation); + const existing = rows.get(key); + if (existing) { + existing.entries.push({ annotation, index }); + return; + } + rows.set(key, { + key, + label: citationSourceLabel(annotation), + source: annotation, + entries: [{ annotation, index }], + }); + }); + return Array.from(rows.values()); +} + +function escapeHtmlText(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function ensureTerminalPeriod(value: string): string { + return /[.!?]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`; +} + +export function buildCitationAppendix(citations: Citation[]) { + if (citations.length === 0) return { html: "", text: "" }; + let previousSourceKey: string | null = null; + const entries = citations.map((annotation) => { + const sourceKey = citationSourceKey(annotation); + const label = + sourceKey === previousSourceKey + ? "Id." + : citationSourceLabel(annotation); + previousSourceKey = sourceKey; + return { + number: annotation.ref, + label, + quote: displayCitationQuote(annotation).trim(), + }; + }); + const textLines = [ + "", + "Citations", + ...entries.map((entry) => { + const quote = entry.quote ? ` "${entry.quote}"` : ""; + return `${entry.number} ${ensureTerminalPeriod(entry.label)}${quote}`; + }), + ]; + const html = [ + `<section class="copied-citations">`, + `<h3>Citations</h3>`, + ...entries.map((entry) => { + const label = escapeHtmlText(ensureTerminalPeriod(entry.label)); + const quote = entry.quote + ? ` "${escapeHtmlText(entry.quote)}"` + : ""; + return `<p><sup>${entry.number}</sup> ${label}${quote}</p>`; + }), + `</section>`, + ].join(""); + return { html, text: textLines.join("\n") }; +} + +export function CitationsBlock({ + citations, + onCitationClick, + onOpenSource, + canOpenSource, + showWhenEmpty = false, + isLoading = false, +}: { + citations: Citation[]; + onCitationClick?: (citation: Citation) => void; + onOpenSource?: (citation: Citation) => void; + canOpenSource?: (citation: Citation) => boolean; + showWhenEmpty?: boolean; + isLoading?: boolean; +}) { + const rows = buildCitationSourceRows(citations); + if (rows.length === 0 && !showWhenEmpty) return null; + + return ( + <div className="mt-2 mb-3"> + <div className={`overflow-hidden ${RESPONSE_GLASS_SURFACE}`}> + <div className="flex items-center justify-between gap-3 bg-white/25 px-3 py-2"> + <h3 className="text-base font-serif text-gray-900"> + Citations + </h3> + {isLoading && ( + <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" /> + )} + </div> + <div> + {rows.map((row) => { + const sourceIsClickable = + !!onOpenSource && + (canOpenSource?.(row.source) ?? true); + return ( + <div + key={row.key} + className="flex items-center gap-3 px-3 py-3" + > + <button + type="button" + onClick={() => onOpenSource?.(row.source)} + disabled={!sourceIsClickable} + className="flex min-w-0 flex-1 items-center gap-2 rounded-lg text-left text-sm font-serif text-gray-700 transition-colors enabled:hover:text-gray-950 disabled:cursor-default" + > + <CitationSourceIcon + annotation={row.source} + /> + <span className="truncate"> + {row.label} + </span> + </button> + <div className="flex shrink-0 flex-wrap justify-end gap-1"> + {row.entries.map( + ({ annotation, index }) => ( + <button + key={`${row.key}:${index}`} + type="button" + onClick={() => + onCitationClick?.( + annotation, + ) + } + className={ + RESPONSE_GLASS_ANNOTATION + } + title={citationTooltip( + annotation, + )} + > + {annotation.ref} + </button> + ), + )} + </div> + </div> + ); + })} + </div> + </div> + </div> + ); +} + diff --git a/frontend/src/app/components/assistant/message/EditCardsSection.tsx b/frontend/src/app/components/assistant/message/EditCardsSection.tsx new file mode 100644 index 0000000..3489fdf --- /dev/null +++ b/frontend/src/app/components/assistant/message/EditCardsSection.tsx @@ -0,0 +1,289 @@ +import { useState, type ReactNode } from "react"; +import { ChevronDown, Loader2 } from "lucide-react"; +import { PillButton } from "@/app/components/ui/pill-button"; +import { supabase } from "@/app/lib/supabase"; +import type { EditAnnotation } from "../../shared/types"; +import { applyOptimisticResolution } from "../EditCard"; + +/** + * Card rendered above the per-edit EditCards when a message produced + * multiple tracked-change proposals. Lets the user resolve every pending + * edit in one click by firing the per-edit accept/reject endpoint for each + * pending annotation and forwarding each response to `onResolved` so the + * parent can bump the viewer version, persist override URLs, etc. + * + * This intentionally doesn't apply the optimistic DOM mutation that + * EditCard does — bulk operations touch many edits at once and the real + * re-render from the latest version will reconcile within a second or so. + */ +function BulkEditActions({ + pending, + onViewClick, + onResolveStart, + onResolved, + onError, +}: { + pending: { + annotation: EditAnnotation; + filename: string; + }[]; + onViewClick?: (ann: EditAnnotation, filename: string) => void; + 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<"accept" | "reject" | null>(null); + const [progress, setProgress] = useState<{ + done: number; + total: number; + } | null>(null); + + if (pending.length === 0) return null; + + const handleAll = async (verb: "accept" | "reject") => { + if (busy) return; + setBusy(verb); + setProgress({ done: 0, total: pending.length }); + 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"; + + // Sequential so the per-document version counter advances in a + // predictable order and the viewer doesn't race between bumps. + let done = 0; + for (const { annotation } of pending) { + onResolveStart?.({ + editId: annotation.edit_id, + documentId: annotation.document_id, + verb, + }); + // Optimistically mutate the DOM so the viewer reflects the + // resolution immediately. Revert if the backend call fails. + let revert: (() => void) | null = null; + try { + revert = applyOptimisticResolution(annotation, verb); + } catch (e) { + console.error( + "[BulkEditActions] optimistic update threw", + e, + ); + } + try { + const resp = await fetch( + `${apiBase}/single-documents/${annotation.document_id}/edits/${annotation.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"); + onResolved?.({ + editId: annotation.edit_id, + documentId: annotation.document_id, + status: nextStatus, + versionId: data.version_id, + downloadUrl: data.download_url, + }); + } catch (e) { + console.error("[BulkEditActions] resolve failed", e); + try { + revert?.(); + } catch (revertErr) { + console.error( + "[BulkEditActions] revert threw", + revertErr, + ); + } + onError?.({ + editId: annotation.edit_id, + documentId: annotation.document_id, + versionId: annotation.version_id ?? null, + message: + verb === "accept" + ? "Couldn't save one or more accepts." + : "Couldn't save one or more rejects.", + }); + } + done++; + setProgress({ done, total: pending.length }); + } + } finally { + setBusy(null); + setProgress(null); + } + }; + + // Optional: show a tiny "View first" action so bulk doesn't lose the + // in-viewer scroll-to behaviour entirely. + const first = pending[0]; + + return ( + <div className="flex items-center gap-2"> + <PillButton + tone="black" + size="sm" + onClick={() => handleAll("accept")} + disabled={!!busy} + > + {busy === "accept" && ( + <Loader2 className="h-3 w-3 animate-spin" /> + )} + Accept all + </PillButton> + <PillButton + tone="white" + size="sm" + onClick={() => handleAll("reject")} + disabled={!!busy} + > + {busy === "reject" && ( + <Loader2 className="h-3 w-3 animate-spin" /> + )} + Reject all + </PillButton> + {progress && ( + <span className="text-xs font-serif text-gray-500"> + {progress.done}/{progress.total} + </span> + )} + {onViewClick && first && ( + <PillButton + tone="blue" + size="sm" + onClick={() => + onViewClick(first.annotation, first.filename) + } + disabled={!!busy} + className="ml-auto" + > + View + </PillButton> + )} + </div> + ); +} + +/** + * Wraps the bulk accept/reject card and the per-edit EditCards in a single + * minimisable container. The bulk actions and summary stay visible in the + * header; the individual cards collapse via the chevron toggle. + */ +export function EditCardsSection({ + pending, + filenameByDocId, + cards, + resolvedCount, + onViewClick, + onResolveStart, + onResolved, + onError, +}: { + pending: { + annotation: EditAnnotation; + filename: string; + }[]; + filenameByDocId: Map<string, string>; + cards: ReactNode[]; + resolvedCount: number; + onViewClick?: (ann: EditAnnotation, filename: string) => void; + 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 [isOpen, setIsOpen] = useState(true); + if (cards.length === 0) return null; + + const docCount = filenameByDocId.size; + const summary = + pending.length > 0 + ? docCount > 1 + ? `${pending.length} tracked changes across ${docCount} documents` + : `${pending.length} tracked ${pending.length === 1 ? "change" : "changes"}` + : docCount > 1 + ? `${resolvedCount} resolved tracked changes across ${docCount} documents` + : `${resolvedCount} resolved tracked ${resolvedCount === 1 ? "change" : "changes"}`; + + return ( + <div className="rounded-xl bg-white 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 overflow-hidden"> + {/* Row 1: summary + chevron */} + <div className="flex items-center gap-2 px-3 pt-3"> + <p className="flex-1 min-w-0 text-sm font-serif text-gray-700 truncate"> + {summary} + </p> + <button + onClick={() => setIsOpen((v) => !v)} + aria-label={isOpen ? "Collapse edits" : "Expand edits"} + className="shrink-0 rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 transition-colors" + > + <ChevronDown + className={`h-4 w-4 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`} + /> + </button> + </div> + {/* Row 2: bulk action buttons */} + {pending.length > 0 && ( + <div className="px-3 pt-3"> + <BulkEditActions + pending={pending} + onViewClick={onViewClick} + onResolveStart={onResolveStart} + onResolved={onResolved} + onError={onError} + /> + </div> + )} + {/* Row 3: collapsible cards list */} + {isOpen && ( + <div className="flex flex-col gap-2 px-3 pb-3 pt-3"> + {cards} + </div> + )} + {!isOpen && <div className="pb-3" />} + </div> + ); +} diff --git a/frontend/src/app/components/assistant/message/EventBlocks.tsx b/frontend/src/app/components/assistant/message/EventBlocks.tsx new file mode 100644 index 0000000..d40f1b4 --- /dev/null +++ b/frontend/src/app/components/assistant/message/EventBlocks.tsx @@ -0,0 +1,701 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { ChevronDown, Download, Loader2 } from "lucide-react"; +import { supabase } from "@/app/lib/supabase"; +import type { AssistantEvent } from "../../shared/types"; +import { FileTypeIcon } from "../../shared/FileTypeIcon"; +import { RESPONSE_GLASS_SURFACE, withoutMarkdownNode } from "./messageStyles"; + +const THINKING_PHRASES = [ + "Thinking...", + "Pondering...", + "Analyzing...", + "Reviewing...", + "Reasoning...", +]; +const REASONING_COLLAPSED_MAX_LINES = 6; +const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9; + +// --------------------------------------------------------------------------- +// Event block primitives +// --------------------------------------------------------------------------- + +function EventConnector() { + return ( + <div className="absolute w-[1px] bg-gray-300 top-[14px] left-[3px] translate-x-[-50%] h-[calc(100%+10px)]" /> + ); +} + +export function EventBlock({ + showConnector, + isStreaming, + dotColor = "green", + children, +}: { + showConnector?: boolean; + isStreaming?: boolean; + dotColor?: "green" | "gray" | "red"; + children: ReactNode; +}) { + const dotColorClass = + dotColor === "green" + ? "bg-green-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]" + : dotColor === "red" + ? "bg-red-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]" + : "bg-gray-300 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.35)]"; + return ( + <div className="flex items-start text-sm font-serif text-gray-500 relative"> + {showConnector && <EventConnector />} + {isStreaming ? ( + <div className="mt-2 w-1.5 h-1.5 shrink-0 rounded-full border border-gray-400 border-t-transparent animate-spin" /> + ) : ( + <div + className={`mt-2 w-1.5 h-1.5 shrink-0 rounded-full ${dotColorClass}`} + /> + )} + <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> + {children} + </div> + </div> + ); +} + +// --------------------------------------------------------------------------- + +export function ReasoningBlock({ + text, + isStreaming, + showConnector, +}: { + text: string; + isStreaming: boolean; + showConnector?: boolean; +}) { + const [isContentOpen, setIsContentOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [userToggledContent, setUserToggledContent] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const [hasMeasured, setHasMeasured] = useState(false); + const [thinkingIndex, setThinkingIndex] = useState(0); + const contentRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + if (!isStreaming) return; + const interval = setInterval(() => { + setThinkingIndex((i) => (i + 1) % THINKING_PHRASES.length); + }, 2000); + return () => clearInterval(interval); + }, [isStreaming]); + + useEffect(() => { + const el = contentRef.current; + if (!el) return; + const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24; + const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES; + const nextOverflowing = el.scrollHeight > maxHeight + 2; + setIsOverflowing(nextOverflowing); + setHasMeasured(true); + if (!userToggledContent) setIsContentOpen(isStreaming); + if (!nextOverflowing) setIsExpanded(false); + }, [isStreaming, text, userToggledContent]); + + const showContent = isContentOpen || isStreaming || !hasMeasured; + const isCollapsed = isContentOpen && isOverflowing && !isExpanded; + + return ( + <EventBlock + showConnector={showConnector} + isStreaming={isStreaming} + dotColor="gray" + > + <button + onClick={() => { + if (isStreaming) return; + setUserToggledContent(true); + setIsContentOpen((v) => !v); + }} + className="flex items-center text-sm font-serif text-gray-500 hover:text-gray-600 transition-colors" + > + <span className="font-medium"> + {isStreaming + ? THINKING_PHRASES[thinkingIndex] + : "Thought process"} + </span> + {!isStreaming && ( + <ChevronDown + size={10} + className={`relative top-px ml-1 transition-transform duration-200 ${isContentOpen ? "" : "-rotate-90"}`} + /> + )} + </button> + {showContent && ( + <div className="mt-2"> + <div + className={`relative ${isCollapsed ? "overflow-hidden" : ""}`} + style={ + isCollapsed + ? { + maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`, + } + : undefined + } + > + <div + ref={contentRef} + className="text-sm font-serif text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm" + > + <ReactMarkdown + remarkPlugins={[remarkGfm]} + components={{ + code: (props) => ( + <code + className="font-serif text-gray-600" + {...withoutMarkdownNode(props)} + /> + ), + }} + > + {text} + </ReactMarkdown> + </div> + {isCollapsed && ( + <> + <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" /> + <button + type="button" + onClick={() => setIsExpanded(true)} + className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600" + aria-label="Expand thought process" + > + <ChevronDown className="h-3.5 w-3.5" /> + </button> + </> + )} + </div> + {isOverflowing && isContentOpen && isExpanded && ( + <button + type="button" + onClick={() => setIsExpanded(false)} + className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600" + aria-label="Minimise thought process" + > + <ChevronDown className="h-3.5 w-3.5 rotate-180" /> + </button> + )} + </div> + )} + </EventBlock> + ); +} + +export function DocReadBlock({ + filename, + onClick, + showConnector, + isStreaming, +}: { + filename: string; + onClick?: () => void; + showConnector?: boolean; + isStreaming?: boolean; +}) { + return ( + <EventBlock + showConnector={showConnector} + isStreaming={isStreaming} + dotColor="green" + > + <div className="flex min-w-0 items-center gap-1.5"> + <span className="shrink-0 font-medium"> + {isStreaming ? "Reading" : "Read"} + </span> + {isStreaming ? ( + <span className="flex min-w-0 items-center gap-1.5"> + <FileTypeIcon + fileType={filename} + className="h-3.5 w-3.5" + /> + <span className="truncate">{filename}...</span> + </span> + ) : onClick ? ( + <button + onClick={onClick} + className="flex min-w-0 items-center gap-1.5 text-left transition-colors hover:text-gray-700 cursor-pointer" + > + <FileTypeIcon + fileType={filename} + className="h-3.5 w-3.5" + /> + <span className="truncate">{filename}</span> + </button> + ) : ( + <span className="flex min-w-0 items-center gap-1.5"> + <FileTypeIcon + fileType={filename} + className="h-3.5 w-3.5" + /> + <span className="truncate">{filename}</span> + </span> + )} + </div> + </EventBlock> + ); +} + +export function DocFindBlock({ + filename, + query, + totalMatches, + isStreaming, + showConnector, +}: { + filename: string; + query: string; + totalMatches: number; + isStreaming?: boolean; + showConnector?: boolean; +}) { + const matchSuffix = isStreaming + ? "" + : ` (${totalMatches} ${totalMatches === 1 ? "match" : "matches"})`; + return ( + <EventBlock + showConnector={showConnector} + isStreaming={isStreaming} + dotColor={totalMatches > 0 ? "green" : "gray"} + > + <span className="font-medium"> + {isStreaming ? "Finding" : "Found"} + </span>{" "} + <span> + “{query}”{matchSuffix} + <span className="ml-1 text-gray-400">in {filename}</span> + {isStreaming && "..."} + </span> + </EventBlock> + ); +} + +export function DocCreatedBlock({ + filename, + showConnector, + isStreaming, +}: { + filename: string; + showConnector?: boolean; + isStreaming?: boolean; +}) { + return ( + <EventBlock + showConnector={showConnector} + isStreaming={isStreaming} + dotColor="green" + > + <span className="font-medium"> + {isStreaming ? "Creating" : "Created"} + </span>{" "} + <span>{isStreaming ? `${filename}...` : filename}</span> + </EventBlock> + ); +} + +export function DocReplicatedBlock({ + filename, + count, + showConnector, + isStreaming, + hasError, +}: { + filename: string; + /** + * How many consecutive replicates of this same source got collapsed + * into this block. ≥ 1; only rendered when > 1. + */ + count: number; + showConnector?: boolean; + isStreaming?: boolean; + hasError?: boolean; +}) { + const label = isStreaming ? "Replicating" : "Replicated"; + const suffix = + !isStreaming && count > 1 + ? ` ${count} times` + : isStreaming + ? "..." + : ""; + return ( + <EventBlock + showConnector={showConnector} + isStreaming={isStreaming} + dotColor={hasError ? "red" : "green"} + > + <span className="font-medium">{label}</span>{" "} + <span> + {filename} + {suffix} + </span> + </EventBlock> + ); +} + +export function DocDownloadBlock({ + filename, + download_url, + onOpen, + isReloading = false, + versionNumber, +}: { + filename: string; + download_url: string; + onOpen?: () => void; + isReloading?: boolean; + versionNumber?: number | null; +}) { + const hasVersion = + typeof versionNumber === "number" && + Number.isFinite(versionNumber) && + versionNumber > 0; + const extMatch = filename.match(/\.(\w+)$/); + const ext = extMatch ? extMatch[1].toUpperCase() : "FILE"; + const rawBasename = extMatch + ? filename.slice(0, -extMatch[0].length) + : filename; + // Strip any legacy "[Edited V3]" suffix that may still be baked into + // older saved download filenames — the version is surfaced as a + // separate tag now. + const basename = rawBasename.replace(/\s*\[Edited V\d+\]\s*$/, "").trim(); + // Only backend-relative URLs are accepted. The download fetch carries + // the user's bearer token, so any absolute URL from tool output is + // refused to keep the token from leaking off-origin. + const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + const isSafeHref = download_url.startsWith("/"); + const href = isSafeHref ? `${API_BASE}${download_url}` : null; + const [busy, setBusy] = useState(false); + + const handleDownload = async (e?: { + stopPropagation?: () => void; + preventDefault?: () => void; + }) => { + e?.stopPropagation?.(); + e?.preventDefault?.(); + if (busy || isReloading || !href) return; + setBusy(true); + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + const token = session?.access_token; + const resp = await fetch(href, { + 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; + + const body = ( + <div className="flex items-center gap-3 px-4 py-3 min-w-0 flex-1"> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2 min-w-0"> + <p className="text-base font-serif text-gray-900 text-wrap"> + {basename} + </p> + {hasVersion && ( + <span className="shrink-0 inline-flex items-center rounded-md border border-white/70 bg-white/55 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl"> + V{versionNumber} + </span> + )} + </div> + <p className="text-xs text-blue-500 mt-0.5">{ext}</p> + </div> + </div> + ); + + const downloadIcon = spinning ? ( + <div + aria-disabled + className="shrink-0 flex items-center bg-white/25 px-6 text-gray-400 cursor-not-allowed" + > + <Loader2 size={13} className="animate-spin" /> + </div> + ) : ( + <button + type="button" + onClick={handleDownload} + className="shrink-0 flex items-center bg-white/25 px-6 text-gray-500 transition-colors hover:bg-white/55 hover:text-gray-700 cursor-pointer" + > + <Download size={13} /> + </button> + ); + + if (onOpen) { + return ( + <div + className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} + > + <button + type="button" + onClick={onOpen} + className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer" + > + {body} + </button> + {downloadIcon} + </div> + ); + } + + if (spinning) { + return ( + <div + className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} + > + {body} + {downloadIcon} + </div> + ); + } + + return ( + <div + className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} + > + <button + type="button" + onClick={handleDownload} + className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer" + > + {body} + </button> + {downloadIcon} + </div> + ); +} + +export function WorkflowAppliedBlock({ + title, + showConnector, + onClick, +}: { + title: string; + showConnector?: boolean; + onClick?: () => void; +}) { + return ( + <EventBlock showConnector={showConnector} dotColor="green"> + <span className="font-medium">Applied Workflow</span>{" "} + {onClick ? ( + <button + onClick={onClick} + className="text-left hover:text-gray-700 transition-colors cursor-pointer" + > + {title} + </button> + ) : ( + <span>{title}</span> + )} + </EventBlock> + ); +} + +export function AskInputsBlock({ + event, + response, + showConnector, +}: { + event: Extract<AssistantEvent, { type: "ask_inputs" }>; + response?: Extract<AssistantEvent, { type: "ask_inputs_response" }>; + showConnector?: boolean; +}) { + const responseById = new Map( + response?.responses.map((item) => [item.id, item]) ?? [], + ); + return ( + <EventBlock + showConnector={showConnector} + dotColor={response ? "green" : "gray"} + > + <p className="font-medium text-gray-600"> + {response ? "Asked for input" : "Asking for input"} + </p> + <div className="mt-2 space-y-2 text-gray-800"> + {event.items.map((item, index) => { + const itemResponse = responseById.get(item.id); + const responseText = (() => { + if (!itemResponse) return null; + if (itemResponse.skipped) return "Skipped"; + if (itemResponse.kind === "choice") { + return itemResponse.answer ?? ""; + } + const filenames = itemResponse.filenames; + return filenames.length + ? filenames.join(", ") + : "No documents attached"; + })(); + return ( + <div key={item.id}> + <p className="text-xs text-gray-500"> + {index + 1}.{" "} + {item.kind === "choice" + ? "Question" + : "Documents"} + </p> + <p className="mt-0.5"> + {item.kind === "choice" + ? item.question + : item.document_types.join(", ") || + "Documents requested"} + </p> + {responseText !== null && ( + <p className="mt-0.5 text-gray-600"> + {responseText} + </p> + )} + </div> + ); + })} + </div> + </EventBlock> + ); +} + +export type CourtListenerBlockItem = { + caseName: string | null; + citation: string | null; + dateFiled?: string | null; + url?: string | null; + query?: string; + totalMatches?: number; + hasError?: boolean; +}; + +export function CourtListenerBlock({ + label, + detail, + isStreaming, + hasError, + showConnector, + items, +}: { + label: string; + detail?: string; + isStreaming?: boolean; + hasError?: boolean; + showConnector?: boolean; + items?: CourtListenerBlockItem[]; +}) { + const [isOpen, setIsOpen] = useState(false); + const hasItems = !!items && items.length > 0; + return ( + <EventBlock + showConnector={showConnector} + isStreaming={isStreaming} + dotColor={hasError ? "red" : "green"} + > + {hasItems ? ( + <button + onClick={() => setIsOpen((v) => !v)} + className="text-left hover:text-gray-700 transition-colors inline-flex items-center" + > + <span className="font-medium">{label}</span> + {detail ? <span> {detail}</span> : null} + {isStreaming ? <span>...</span> : null} + <ChevronDown + size={10} + className={`relative top-px ml-1 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`} + /> + </button> + ) : ( + <> + <span className="font-medium">{label}</span> + {detail ? <span> {detail}</span> : null} + {isStreaming ? <span>...</span> : null} + </> + )} + {isOpen && hasItems && ( + <ul className="mt-2 flex flex-col gap-1 text-sm font-serif text-gray-500"> + {items!.map((item, idx) => { + const label = [item.caseName, item.citation] + .filter(Boolean) + .join(", "); + const primary = label || item.url || "Unknown case"; + const searchText = item.query + ? `Searched for "${item.query}" in ${primary}${ + typeof item.totalMatches === "number" + ? ` (${item.totalMatches} ${ + item.totalMatches === 1 + ? "match" + : "matches" + })` + : "" + }` + : null; + return ( + <li key={idx}> + <div + className={ + item.hasError ? "text-red-500" : "" + } + > + {item.url ? ( + <a + href={item.url} + target="_blank" + rel="noreferrer" + className="hover:text-gray-700 hover:underline underline-offset-2" + > + {searchText ?? primary} + </a> + ) : searchText ? ( + <span>{searchText}</span> + ) : ( + <span>{primary}</span> + )} + </div> + </li> + ); + })} + </ul> + )} + </EventBlock> + ); +} + +export function DocEditedBlock({ + filename, + showConnector, + isStreaming, + hasError, +}: { + filename: string; + showConnector?: boolean; + isStreaming?: boolean; + hasError?: boolean; +}) { + return ( + <EventBlock + showConnector={showConnector} + isStreaming={isStreaming} + dotColor={hasError ? "red" : "green"} + > + <span className="font-medium"> + {isStreaming ? "Editing" : hasError ? "Edit failed" : "Edited"} + </span>{" "} + <span>{isStreaming ? `${filename}...` : filename}</span> + </EventBlock> + ); +} diff --git a/frontend/src/app/components/assistant/message/MarkdownContent.tsx b/frontend/src/app/components/assistant/message/MarkdownContent.tsx new file mode 100644 index 0000000..da605aa --- /dev/null +++ b/frontend/src/app/components/assistant/message/MarkdownContent.tsx @@ -0,0 +1,278 @@ +import type { RefObject } from "react"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import remarkMath from "remark-math"; +import remarkGfm from "remark-gfm"; +import rehypeKatex from "rehype-katex"; +import "katex/dist/katex.min.css"; +import type { AssistantEvent, Citation } from "../../shared/types"; +import { RESPONSE_GLASS_ANNOTATION, withoutMarkdownNode } from "./messageStyles"; +import { citationTooltip } from "./CitationSources"; +import { internalCaseHref } from "./citationUtils"; + +export function MarkdownContent({ + text, + inlineCitationTargets, + caseCitations, + caseOpinions, + onCitationClick, + onCaseClick, + divRef, +}: { + text: string; + inlineCitationTargets: Citation[]; + caseCitations: Map< + string, + Extract<AssistantEvent, { type: "case_citation" }> + >; + caseOpinions: Map< + number, + Extract<AssistantEvent, { type: "case_opinions" }>["case"] + >; + onCitationClick?: (c: Citation) => void; + onCaseClick?: ( + c: Extract<AssistantEvent, { type: "case_citation" }>, + ) => void; + divRef?: RefObject<HTMLDivElement | null>; +}) { + function findCaseCitation(href: string) { + return caseCitations.get(internalCaseHref(href) ?? ""); + } + + return ( + <div + ref={divRef} + className="text-gray-900 mb-4 text-base prose prose-sm max-w-none font-serif" + > + <ReactMarkdown + remarkPlugins={[ + [remarkMath, { singleDollarTextMath: false }], + remarkGfm, + ]} + rehypePlugins={[rehypeKatex]} + urlTransform={(url) => + /^us-case-\d+$/.test(url) ? url : defaultUrlTransform(url) + } + components={{ + table: (props) => ( + <div className="overflow-x-auto my-4 rounded-lg"> + <table + className="min-w-full divide-y divide-gray-300 overflow-hidden" + {...withoutMarkdownNode(props)} + /> + </div> + ), + thead: (props) => ( + <thead + className="bg-gray-100" + {...withoutMarkdownNode(props)} + /> + ), + tbody: (props) => ( + <tbody + className="divide-y divide-gray-200" + {...withoutMarkdownNode(props)} + /> + ), + tr: (props) => <tr {...withoutMarkdownNode(props)} />, + th: (props) => ( + <th + className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + {...withoutMarkdownNode(props)} + /> + ), + td: (props) => ( + <td + className="whitespace-normal px-3 py-4 text-sm text-gray-900" + {...withoutMarkdownNode(props)} + /> + ), + h1: (props) => ( + <h1 + className="mt-6 mb-4 text-3xl font-serif font-semibold" + {...withoutMarkdownNode(props)} + /> + ), + h2: (props) => ( + <h2 + className="mt-5 mb-3 text-2xl font-serif font-semibold" + {...withoutMarkdownNode(props)} + /> + ), + h3: (props) => ( + <h3 + className="text-xl font-semibold mt-4 mb-2" + {...withoutMarkdownNode(props)} + /> + ), + h4: (props) => ( + <h4 + className="text-lg font-semibold mt-4 mb-2" + {...withoutMarkdownNode(props)} + /> + ), + p: ({ node, ...props }) => { + const parent = + node && typeof node === "object" && "parent" in node + ? (node as { parent?: { type?: string } }) + .parent + : undefined; + if (parent?.type === "listItem") { + return ( + <p + className="inline leading-7 m-0" + {...props} + /> + ); + } + return <p className="mb-4 leading-7" {...props} />; + }, + ul: (props) => ( + <ul + className="list-disc list-outside mb-4 pl-6" + {...withoutMarkdownNode(props)} + /> + ), + ol: (props) => ( + <ol + className="list-decimal list-outside mb-4 pl-6" + {...withoutMarkdownNode(props)} + /> + ), + li: (props) => ( + <li + className="mb-2 leading-7" + {...withoutMarkdownNode(props)} + /> + ), + strong: (props) => ( + <strong + className="font-semibold" + {...withoutMarkdownNode(props)} + /> + ), + em: (props) => ( + <em className="italic" {...withoutMarkdownNode(props)} /> + ), + code: (props) => { + const { children, ...codeProps } = + withoutMarkdownNode(props); + const text = String(children); + const citMatch = text.match(/^§(\d+)§$/); + if (citMatch) { + const idx = parseInt(citMatch[1]); + const annotation = inlineCitationTargets[idx]; + if (annotation) { + const tooltipText = citationTooltip(annotation); + return ( + <button + onClick={() => + onCitationClick?.(annotation) + } + data-citation-ref={annotation.ref} + className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`} + title={tooltipText} + > + {annotation.ref} + </button> + ); + } + } + return ( + <code + className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-serif" + {...codeProps} + > + {children} + </code> + ); + }, + blockquote: (props) => ( + <blockquote + className="border-l-4 border-gray-300 pl-4 italic my-4" + {...withoutMarkdownNode(props)} + /> + ), + a: (props) => { + const { href, children, ...anchorProps } = + withoutMarkdownNode(props); + if (href) { + const isInternalCaseHref = !!internalCaseHref(href); + const citation = findCaseCitation(href); + if (citation && onCaseClick) { + return ( + <button + type="button" + onClick={() => + onCaseClick({ + ...citation, + case: + citation.cluster_id !== null + ? caseOpinions.get( + citation.cluster_id, + ) + : undefined, + }) + } + className="text-left text-blue-600 hover:text-blue-700 underline" + > + {children} + </button> + ); + } + if (citation) { + return ( + <a + href={citation.url} + className="text-blue-600 hover:text-blue-700 underline" + target="_blank" + rel="noopener noreferrer" + > + {children} + </a> + ); + } + if (isInternalCaseHref) { + return ( + <span className="text-blue-600 underline"> + {children} + </span> + ); + } + return ( + <a + href={href} + className="text-blue-600 hover:text-blue-700 underline" + target="_blank" + rel="noopener noreferrer" + {...anchorProps} + > + {children} + </a> + ); + } + return ( + <a + href={href} + className="text-blue-600 hover:text-blue-700 underline" + target="_blank" + rel="noopener noreferrer" + {...anchorProps} + > + {children} + </a> + ); + }, + hr: (props) => ( + <hr + className="my-6 border-gray-200" + {...withoutMarkdownNode(props)} + /> + ), + }} + > + {text} + </ReactMarkdown> + </div> + ); +} + diff --git a/frontend/src/app/components/assistant/message/ResponseStatus.tsx b/frontend/src/app/components/assistant/message/ResponseStatus.tsx new file mode 100644 index 0000000..08bfac7 --- /dev/null +++ b/frontend/src/app/components/assistant/message/ResponseStatus.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from "react"; +import { MikeIcon } from "@/app/components/chat/mike-icon"; + +export type StatusState = "active" | "error" | null; + +export function ResponseStatus({ status }: { status: StatusState }) { + const [showDone, setShowDone] = useState(false); + const [doneVisible, setDoneVisible] = useState(false); + const wasActiveRef = useRef(false); + + const isActive = status === "active"; + const isError = status === "error"; + + useEffect(() => { + const wasActive = wasActiveRef.current; + wasActiveRef.current = isActive; + + let raf = 0; + let doneTimeout = 0; + if (wasActive && !isActive) { + raf = window.requestAnimationFrame(() => { + setShowDone(true); + setDoneVisible(true); + doneTimeout = window.setTimeout( + () => setDoneVisible(false), + 1500, + ); + }); + } else if (!wasActive && isActive) { + raf = window.requestAnimationFrame(() => { + setShowDone(false); + setDoneVisible(false); + }); + } + + return () => { + window.cancelAnimationFrame(raf); + if (doneTimeout) window.clearTimeout(doneTimeout); + }; + }, [isActive]); + + return ( + <div className="w-full h-9 flex items-center mb-2"> + <MikeIcon + spin={isActive} + done={showDone && doneVisible} + error={isError} + mike={!isError && !(showDone && doneVisible)} + size={22} + /> + </div> + ); +} diff --git a/frontend/src/app/components/assistant/message/citationUtils.ts b/frontend/src/app/components/assistant/message/citationUtils.ts new file mode 100644 index 0000000..2c37d08 --- /dev/null +++ b/frontend/src/app/components/assistant/message/citationUtils.ts @@ -0,0 +1,36 @@ +import type { Citation } from "../../shared/types"; + +export function preprocessCitations( + text: string, + citations: Citation[], + inlineCitationTargets: Citation[], +): string { + // Replace [N] or [N, M, ...] inline markers with internal §idx§ tokens backed by citations. + return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr) => { + const refs = (refsStr as string) + .split(",") + .map((s: string) => parseInt(s.trim(), 10)); + const tokens = refs.flatMap((ref: number) => { + const citation = citations.find((a) => a.ref === ref); + if (!citation) return []; + const idx = inlineCitationTargets.length; + inlineCitationTargets.push(citation); + return [`\`§${idx}§\`\u200B`]; + }); + return tokens.length > 0 ? tokens.join("") : full; + }); +} + +// --------------------------------------------------------------------------- +// Markdown renderer (shared config) +// --------------------------------------------------------------------------- + +export function internalCaseHref( + value: string | number | null | undefined, +): string | null { + if (typeof value === "number") return `us-case-${value}`; + if (!value) return null; + const match = value.match(/^us-case-(\d+)$/); + return match ? `us-case-${match[1]}` : null; +} + diff --git a/frontend/src/app/components/assistant/message/eventUtils.ts b/frontend/src/app/components/assistant/message/eventUtils.ts new file mode 100644 index 0000000..520e418 --- /dev/null +++ b/frontend/src/app/components/assistant/message/eventUtils.ts @@ -0,0 +1,33 @@ +import type { AssistantEvent } from "../../shared/types"; + +export function eventErrorMessage(event: AssistantEvent): string | null { + if (event.type === "error") return event.message; + if ("error" in event && typeof event.error === "string" && event.error) { + return event.error; + } + return null; +} + +export function toolCallLabel(name: string): string { + if (name === "ask_inputs") return "Asking for input..."; + if (name === "generate_docx") return "Creating document..."; + if (name === "generate_excel") return "Creating spreadsheet..."; + if (name === "generate_ppt") return "Creating presentation..."; + if (name === "edit_document") return "Editing document..."; + if (name === "read_document") return "Reading document..."; + if (name === "fetch_documents") return "Reading documents..."; + if (name === "find_in_document") return "Searching document..."; + if (name === "replicate_document") return "Copying document..."; + if (name === "read_workflow") return "Loading workflow..."; + if (name === "list_workflows") return "Loading workflows..."; + if (name === "list_documents") return "Loading documents..."; + if (name === "courtlistener_search_case_law") + return "Searching case law..."; + if (name === "courtlistener_get_cases") return "Fetching cases..."; + if (name === "courtlistener_find_in_case") return "Searching case..."; + if (name === "courtlistener_read_case") return "Reading case..."; + if (name === "courtlistener_verify_citations") + return "Verifying citations..."; + if (name.startsWith("mcp_")) return "Using connector..."; + return name ? `Running ${name}...` : "Working..."; +} diff --git a/frontend/src/app/components/assistant/message/messageStyles.ts b/frontend/src/app/components/assistant/message/messageStyles.ts new file mode 100644 index 0000000..917ae72 --- /dev/null +++ b/frontend/src/app/components/assistant/message/messageStyles.ts @@ -0,0 +1,12 @@ +export const RESPONSE_GLASS_SURFACE = + "rounded-xl border border-white/70 bg-white/55 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"; +export const RESPONSE_GLASS_ANNOTATION = + "inline-flex h-4 w-4 items-center justify-center rounded-full border border-gray-200/60 bg-gray-200/80 text-[12px] font-serif font-medium text-gray-800 shadow-[0_1px_2px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(243,244,246,0.85),inset_0_-2px_4px_rgba(229,231,235,0.65)] backdrop-blur-xl transition-colors hover:bg-gray-200 hover:text-gray-950"; + +export function withoutMarkdownNode<P extends { node?: unknown }>( + props: P, +): Omit<P, "node"> { + const { node, ...rest } = props; + void node; + return rest; +} diff --git a/frontend/src/app/components/assistant/message/useSmoothedReveal.ts b/frontend/src/app/components/assistant/message/useSmoothedReveal.ts new file mode 100644 index 0000000..13bbda0 --- /dev/null +++ b/frontend/src/app/components/assistant/message/useSmoothedReveal.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Hide jitter from arrival of streamed text chunks by revealing characters at + * a smooth, rate-paced clip rather than rendering every chunk verbatim. + * + * Returns a prefix of `text` whose length grows over time toward the full + * length. When `active` is false (stream ended, message replayed from + * history, etc.), snaps to the full text immediately. + * + * Rate adapts to backlog: small backlogs reveal at a 40 cps floor; large + * backlogs catch up within ~0.4s, so the smoothing never lags noticeably + * behind the server. + */ +export function useSmoothedReveal(text: string, active: boolean): string { + const [revealedInt, setRevealedInt] = useState(text.length); + const revealedFloat = useRef<number>(text.length); + + useEffect(() => { + if (!active) { + revealedFloat.current = text.length; + return; + } + + // Defensive clamp in case the text was edited / replaced shorter. + if (revealedFloat.current > text.length) { + revealedFloat.current = text.length; + } + + let lastTick = performance.now(); + let raf = 0; + let cancelled = false; + + const step = (now: number) => { + if (cancelled) return; + const dt = Math.max(0, (now - lastTick) / 1000); + lastTick = now; + const target = text.length; + const prev = revealedFloat.current; + if (prev < target) { + const backlog = target - prev; + const cps = Math.max(40, backlog / 0.4); + const next = Math.min(target, prev + cps * dt); + revealedFloat.current = next; + const nextInt = Math.floor(next); + setRevealedInt((cur) => (cur === nextInt ? cur : nextInt)); + } + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + }; + }, [text.length, active]); + + return text.slice(0, Math.min(revealedInt, text.length)); +} + diff --git a/frontend/src/components/chat/mike-icon.tsx b/frontend/src/app/components/chat/mike-icon.tsx similarity index 100% rename from frontend/src/components/chat/mike-icon.tsx rename to frontend/src/app/components/chat/mike-icon.tsx diff --git a/frontend/src/app/components/modals/AddDocumentsModal.tsx b/frontend/src/app/components/modals/AddDocumentsModal.tsx new file mode 100644 index 0000000..8d1d19d --- /dev/null +++ b/frontend/src/app/components/modals/AddDocumentsModal.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { AlertCircle, Upload, Loader2, X } from "lucide-react"; +import { + uploadStandaloneDocument, + uploadProjectDocument, + addDocumentToProject, +} from "@/app/lib/mikeApi"; +import type { Document } from "../shared/types"; +import { FileDirectory } from "../shared/FileDirectory"; +import { + useDirectoryData, + invalidateDirectoryCache, +} from "../shared/useDirectoryData"; +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 [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 [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]); + const fileInputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + if (!open) return; + setSelectedIds(new Set()); + setExtraUploadedDocs([]); + setUploadingFilenames([]); + setUploadWarning(null); + }, [open]); + + if (!open) return null; + + const allStandalone = [ + ...extraUploadedDocs.filter( + (u) => !standaloneDocuments.some((d) => d.id === u.id), + ), + ...standaloneDocuments, + ]; + + const availableProjects = projects + .filter((p) => p.id !== projectId) + .map((p) => ({ + ...p, + documents: p.documents || [], + })); + + const allDocs = [ + ...allStandalone, + ...availableProjects.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 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} + /> + + {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> + )} + + <div className="flex min-h-0 flex-1 flex-col"> + <FileDirectory + standaloneDocs={allStandalone} + directoryProjects={availableProjects} + loading={loading} + selectedIds={selectedIds} + onChange={setSelectedIds} + allowMultiple={allowMultiple} + emptyMessage="No documents yet" + uploadingFilenames={uploadingFilenames} + searchable + searchAutoFocus + searchNoResultsMessage="No matches found" + showProjectTabs + /> + </div> + </Modal> + ); +} diff --git a/frontend/src/app/components/shared/AddProjectDocsModal.tsx b/frontend/src/app/components/modals/AddProjectDocsModal.tsx similarity index 85% rename from frontend/src/app/components/shared/AddProjectDocsModal.tsx rename to frontend/src/app/components/modals/AddProjectDocsModal.tsx index 88fe3b6..45b6218 100644 --- a/frontend/src/app/components/shared/AddProjectDocsModal.tsx +++ b/frontend/src/app/components/modals/AddProjectDocsModal.tsx @@ -1,11 +1,12 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Check, Loader2, Search, Upload, X } from "lucide-react"; +import { Check, Loader2, Upload, X } from "lucide-react"; +import { SearchBar } from "@/app/components/ui/search-bar"; import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi"; -import type { Document } from "./types"; -import { DocFileIcon } from "./FileDirectory"; -import { VersionChip } from "./VersionChip"; +import type { Document } from "../shared/types"; +import { DocFileIcon } from "../shared/FileDirectory"; +import { VersionChip } from "../shared/VersionChip"; import { Modal } from "./Modal"; interface Props { @@ -146,35 +147,22 @@ export function AddProjectDocsModal({ <input ref={fileInputRef} type="file" - accept=".pdf,.docx,.doc" + accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt" 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> + <SearchBar + value={search} + onValueChange={setSearch} + placeholder="Search..." + autoFocus + /> </div> {/* File list */} + <div className="min-h-0 flex-1 overflow-y-auto"> {loading ? ( <div className="rounded-sm border border-gray-100 overflow-hidden"> {[60, 45, 75, 55, 40].map((w, i) => ( @@ -183,9 +171,9 @@ export function AddProjectDocsModal({ 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.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> @@ -206,12 +194,12 @@ export function AddProjectDocsModal({ 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 ${ + className={`w-full flex items-center gap-2 rounded-md px-2 py-2 text-xs text-left transition-all ${ excluded ? "opacity-50 cursor-not-allowed" : checked ? "bg-gray-100" - : "hover:bg-gray-50" + : "hover:bg-gray-100/70" }`} > <span @@ -256,6 +244,7 @@ export function AddProjectDocsModal({ })} </div> )} + </div> </Modal> ); } diff --git a/frontend/src/app/components/modals/Modal.tsx b/frontend/src/app/components/modals/Modal.tsx new file mode 100644 index 0000000..76f12b9 --- /dev/null +++ b/frontend/src/app/components/modals/Modal.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { createPortal } from "react-dom"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { X } from "lucide-react"; +import { PillButton } from "@/app/components/ui/pill-button"; +import { cn } from "@/app/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[]; + headerAction?: ReactNode; + size?: ModalSize; + className?: string; + 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, + headerAction, + size = "lg", + className, + footerStatus, + primaryAction, + secondaryAction, + cancelAction, +}: ModalProps) { + const hasHeader = breadcrumbs?.length; + const hasFooter = + footerStatus || + primaryAction || + secondaryAction || + cancelAction; + const resolvedCancelAction = cancelAction; + + if (!open) return null; + + return createPortal( + <div + className={cn( + "fixed inset-0 z-[200] flex items-center justify-center px-4", + "bg-white/10 backdrop-blur-[2px]", + )} + onClick={onClose} + > + <div + className={cn( + "w-full rounded-3xl flex h-[600px] flex-col", + sizeClassName[size], + "border border-white/70 bg-gray-50/95 shadow-[0_14px_40px_rgba(15,23,42,0.101),0_5px_14px_rgba(15,23,42,0.067)] backdrop-blur-3xl", + className, + )} + onClick={(e) => e.stopPropagation()} + > + {hasHeader && ( + <div className="flex items-center justify-between gap-3 p-4 pl-5"> + <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 leading-none text-gray-400"> + {breadcrumbs?.map((segment, index) => ( + <span + key={index} + className="flex items-center gap-1.5" + > + {index > 0 && <span>›</span>} + <span + className={cn( + "truncate", + index === + (breadcrumbs?.length ?? 0) - + 1 && "text-gray-700", + )} + > + {segment} + </span> + </span> + ))} + </div> + {headerAction} + </div> + <button + onClick={onClose} + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700" + aria-label="Close" + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + )} + {/* Body never scrolls itself (so children's edge shadows are + never clipped by the header/footer). Content that can exceed + the modal height wraps its scrollable region in an inner + `overflow-y-auto` div. */} + <div className="flex min-h-0 flex-1 flex-col px-5"> + {children} + </div> + {hasFooter && ( + <div + className={cn( + "flex items-center gap-3 p-3", + secondaryAction + ? "justify-between" + : "justify-end", + "border-t border-white/60", + )} + > + {secondaryAction && ( + <div className="flex min-w-0 items-center gap-2"> + <ModalActionButton + action={secondaryAction} + fallbackVariant="secondary" + /> + </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; + + if (fallbackVariant === "cancel") { + return ( + <button + type="button" + className="px-2 py-1.5 text-sm text-gray-500 transition-colors hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-40" + {...props} + > + {label} + </button> + ); + } + + const tone = + variant === "danger" + ? "danger" + : fallbackVariant === "secondary" && variant === "secondary" + ? "blue" + : variant === "primary" + ? "black" + : "white"; + + return ( + <PillButton tone={tone} size="normal" {...props}> + {icon} + {label} + </PillButton> + ); +} diff --git a/frontend/src/app/components/modals/ModalFieldLabel.tsx b/frontend/src/app/components/modals/ModalFieldLabel.tsx new file mode 100644 index 0000000..39be59c --- /dev/null +++ b/frontend/src/app/components/modals/ModalFieldLabel.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { cn } from "@/app/lib/utils"; + +type ModalFieldLabelProps = ComponentPropsWithoutRef<"label"> & { + children: ReactNode; + as?: "label" | "p" | "span"; +}; + +export function ModalFieldLabel({ + as = "label", + children, + className, + ...props +}: ModalFieldLabelProps) { + const classes = cn( + "mb-2 block text-xs font-medium text-gray-700", + className, + ); + + if (as === "p") { + return <p className={classes}>{children}</p>; + } + + if (as === "span") { + return <span className={classes}>{children}</span>; + } + + return ( + <label className={classes} {...props}> + {children} + </label> + ); +} diff --git a/frontend/src/app/components/modals/ModalSegmentedToggle.tsx b/frontend/src/app/components/modals/ModalSegmentedToggle.tsx new file mode 100644 index 0000000..7a8e432 --- /dev/null +++ b/frontend/src/app/components/modals/ModalSegmentedToggle.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/app/lib/utils"; + +export interface SegmentedToggleOption<T extends string> { + value: T; + label: string; + icon?: LucideIcon; +} + +interface ModalSegmentedToggleProps<T extends string> { + value: T; + onChange: (value: T) => void; + options: SegmentedToggleOption<T>[]; + disabled?: boolean; + size?: "sm" | "md"; + className?: string; +} + +export function ModalSegmentedToggle<T extends string>({ + value, + onChange, + options, + disabled = false, + size = "md", + className, +}: ModalSegmentedToggleProps<T>) { + return ( + <div + className={cn( + "inline-grid gap-1 rounded-full bg-gray-100", + size === "sm" ? "h-8 p-1" : "h-9 p-1", + className, + )} + style={{ + gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`, + }} + > + {options.map((option) => { + const Icon = option.icon; + const active = option.value === value; + return ( + <button + key={option.value} + type="button" + onClick={() => onChange(option.value)} + disabled={disabled} + aria-pressed={active} + className={cn( + "flex h-full items-center justify-center rounded-full text-xs transition-all disabled:cursor-not-allowed disabled:opacity-60", + size === "sm" ? "gap-1 px-3" : "gap-1.5 px-3", + active + ? "bg-white/80 text-gray-900 shadow-[0_5px_16px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.62)] backdrop-blur-xl" + : "text-gray-500 hover:text-gray-700", + )} + > + {Icon && ( + <Icon + className={ + size === "sm" ? "h-2.5 w-2.5" : "h-3 w-3" + } + /> + )} + {option.label} + </button> + ); + })} + </div> + ); +} diff --git a/frontend/src/app/components/modals/ModalSelect.tsx b/frontend/src/app/components/modals/ModalSelect.tsx new file mode 100644 index 0000000..c17bb29 --- /dev/null +++ b/frontend/src/app/components/modals/ModalSelect.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, type LucideIcon } from "lucide-react"; +import { cn } from "@/app/lib/utils"; + +export type ModalSelectOption = + | string + | { + value: string; + label: string; + icon?: LucideIcon; + iconClassName?: string; + }; + +interface ModalSelectProps { + id: string; + value: string; + options: readonly ModalSelectOption[]; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + className?: string; + menuClassName?: string; +} + +function normalizeOption(option: ModalSelectOption) { + return typeof option === "string" + ? { value: option, label: option } + : option; +} + +export function ModalSelect({ + id, + value, + options, + onChange, + placeholder = "Select...", + disabled = false, + open, + onOpenChange, + className, + menuClassName, +}: ModalSelectProps) { + const [internalOpen, setInternalOpen] = useState(false); + const isOpen = open ?? internalOpen; + const normalizedOptions = options.map(normalizeOption); + const selected = normalizedOptions.find((option) => option.value === value); + const hasValue = value.trim().length > 0; + + function setOpen(next: boolean) { + onOpenChange?.(next); + if (open === undefined) { + setInternalOpen(next); + } + } + + function handleSelect(nextValue: string) { + setOpen(false); + onChange(nextValue); + } + + return ( + <div className="relative"> + <button + id={id} + type="button" + onClick={() => setOpen(!isOpen)} + disabled={disabled} + className={cn( + "flex h-10 w-full items-center justify-between rounded-xl border border-white/70 bg-white/55 px-3 text-sm text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors hover:bg-white/70 focus:bg-white/70 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60", + className, + )} + aria-haspopup="listbox" + aria-expanded={isOpen} + > + <span className="flex min-w-0 items-center gap-2"> + {selected?.icon && ( + <selected.icon + className={cn( + "h-3.5 w-3.5 shrink-0", + selected.iconClassName, + )} + /> + )} + <span + className={cn( + "truncate", + !selected && !hasValue && "text-gray-400", + )} + > + {selected?.label ?? (hasValue ? value : placeholder)} + </span> + </span> + <ChevronDown + className={cn( + "ml-2 h-3.5 w-3.5 shrink-0 text-gray-400 transition-transform", + isOpen && "rotate-180", + )} + /> + </button> + {isOpen && !disabled && ( + <div + role="listbox" + aria-labelledby={id} + className={cn( + "absolute left-0 top-full z-30 mt-1 max-h-56 w-full overflow-y-auto rounded-2xl border border-white/70 bg-gray-50/95 p-1 shadow-[0_12px_32px_rgba(15,23,42,0.156),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-2xl", + menuClassName, + )} + > + {normalizedOptions.map((option) => ( + <button + key={option.value} + type="button" + role="option" + aria-selected={option.value === value} + onClick={() => handleSelect(option.value)} + className={cn( + "flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-all hover:bg-gray-100/70", + option.value === value + ? "bg-gray-100 text-gray-900" + : "text-gray-700", + )} + > + <span className="flex min-w-0 items-center gap-2"> + {option.icon && ( + <option.icon + className={cn( + "h-3.5 w-3.5 shrink-0", + option.iconClassName, + )} + /> + )} + <span className="truncate"> + {option.label} + </span> + </span> + </button> + ))} + </div> + )} + </div> + ); +} diff --git a/frontend/src/app/components/modals/ModalTextInput.tsx b/frontend/src/app/components/modals/ModalTextInput.tsx new file mode 100644 index 0000000..3525976 --- /dev/null +++ b/frontend/src/app/components/modals/ModalTextInput.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { forwardRef, type InputHTMLAttributes } from "react"; +import { cn } from "@/app/lib/utils"; + +type ModalTextInputVariant = "glass" | "minimal"; + +type ModalTextInputProps = InputHTMLAttributes<HTMLInputElement> & { + variant?: ModalTextInputVariant; +}; + +const variantClasses: Record<ModalTextInputVariant, string> = { + glass: "h-10 w-full rounded-xl border border-white/70 bg-white px-3 text-sm text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] outline-none placeholder:text-gray-400 backdrop-blur-xl transition-colors disabled:cursor-not-allowed disabled:opacity-60", + minimal: + "w-full bg-transparent font-serif text-2xl text-gray-800 outline-none placeholder:text-gray-300 disabled:cursor-not-allowed disabled:text-gray-400", +}; + +export const ModalTextInput = forwardRef<HTMLInputElement, ModalTextInputProps>( + ({ className, variant = "glass", ...props }, ref) => ( + <input + ref={ref} + className={cn(variantClasses[variant], className)} + {...props} + /> + ), +); + +ModalTextInput.displayName = "ModalTextInput"; diff --git a/frontend/src/app/components/modals/ModalTextarea.tsx b/frontend/src/app/components/modals/ModalTextarea.tsx new file mode 100644 index 0000000..c10133f --- /dev/null +++ b/frontend/src/app/components/modals/ModalTextarea.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { forwardRef, type TextareaHTMLAttributes } from "react"; +import { cn } from "@/app/lib/utils"; + +type ModalTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>; + +export const ModalTextarea = forwardRef< + HTMLTextAreaElement, + ModalTextareaProps +>(({ className, ...props }, ref) => ( + <textarea + ref={ref} + className={cn( + "min-h-24 w-full resize-none rounded-xl border border-white/70 bg-white px-3 py-2.5 text-sm leading-relaxed text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] outline-none placeholder:text-gray-400 backdrop-blur-xl transition-colors disabled:cursor-not-allowed disabled:opacity-60", + className, + )} + {...props} + /> +)); + +ModalTextarea.displayName = "ModalTextarea"; diff --git a/frontend/src/app/components/modals/PeopleModal.tsx b/frontend/src/app/components/modals/PeopleModal.tsx new file mode 100644 index 0000000..b94ea8f --- /dev/null +++ b/frontend/src/app/components/modals/PeopleModal.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { User, Loader2 } from "lucide-react"; +import type { ProjectPeople } from "@/app/lib/mikeApi"; +import { AddUserInput } from "../shared/AddUserInput"; +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; + owner_display_name?: string | null; + owner_email?: 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; +} + +type RosterRow = { + email: string | null; + user_id?: string | null; + 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 [busy, setBusy] = useState<"add" | "remove" | null>(null); + const [removingEmail, setRemovingEmail] = useState<string | null>(null); + const [memberMenuEmail, setMemberMenuEmail] = 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 [lookupDisplayByEmail, setLookupDisplayByEmail] = useState< + Map<string, string | null> + >(new Map()); + const [peopleLoading, setPeopleLoading] = useState(false); + const [loadedRosterKey, setLoadedRosterKey] = useState<string | null>(null); + + const resourceId = resource?.id ?? null; + const sharedWith: string[] = useMemo( + () => + Array.isArray(resource?.shared_with) + ? (resource.shared_with as string[]) + : [], + [resource?.shared_with], + ); + + useEffect(() => { + if (!open) return; + setError(null); + setBusy(null); + setRemovingEmail(null); + setMemberMenuEmail(null); + }, [open]); + + useEffect(() => { + if (!memberMenuEmail) return; + function handleClickAway(event: PointerEvent) { + const target = event.target; + if ( + target instanceof HTMLElement && + target.closest("[data-people-member-menu]") + ) { + return; + } + setMemberMenuEmail(null); + } + document.addEventListener("pointerdown", handleClickAway); + return () => + document.removeEventListener("pointerdown", handleClickAway); + }, [memberMenuEmail]); + + // 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(","); + const rosterKey = `${resourceId ?? ""}:${sharedKey}`; + + useEffect(() => { + if (!open || !resourceId) return; + let cancelled = false; + setPeopleLoading(true); + setPeople(null); + setLoadedRosterKey(null); + fetchPeople(resourceId) + .then((data) => { + if (cancelled) return; + setPeople(data); + setLoadedRosterKey(rosterKey); + }) + .catch(() => { + if (!cancelled) setLoadedRosterKey(rosterKey); + }) + .finally(() => { + if (!cancelled) setPeopleLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, resourceId, rosterKey, 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?.trim().toLowerCase() ?? + resource.owner_email?.trim().toLowerCase() ?? + null; + const ownerDisplayName = + people?.owner.display_name ?? resource.owner_display_name ?? null; + + const roster: RosterRow[] = []; + if (people?.owner || ownerEmail || ownerDisplayName) { + roster.push({ + email: ownerEmail, + user_id: people?.owner.user_id ?? null, + 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) ?? + lookupDisplayByEmail.get(lower) ?? + null, + role: "member", + }); + } + + const normalizedCurrentUserEmail = + currentUserEmail?.trim().toLowerCase() ?? null; + const sharedLower = sharedWith.map((e) => e.toLowerCase()); + const rosterPending = peopleLoading || loadedRosterKey !== rosterKey; + + function validateNewEmail(email: string) { + if (sharedLower.includes(email)) return `${email} already has access.`; + if (ownerEmail && email === ownerEmail.toLowerCase()) { + return `${email} is the owner.`; + } + if ( + normalizedCurrentUserEmail && + email === normalizedCurrentUserEmail + ) { + return "You cannot share this with yourself."; + } + return null; + } + + async function handleAddUser(user: { + email: string; + display_name: string | null; + }) { + setLookupDisplayByEmail((prev) => { + const next = new Map(prev); + next.set(user.email.trim().toLowerCase(), user.display_name); + return next; + }); + await handleAdd(user.email); + } + + async function handleAdd(email: string) { + if (!onSharedWithChange || busy !== null) return; + setBusy("add"); + setError(null); + try { + const next = [...sharedWith, email]; + await onSharedWithChange(next); + } catch (e) { + throw new Error( + 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); + setMemberMenuEmail(null); + } + } + + return ( + <Modal open={open} onClose={onClose} breadcrumbs={breadcrumb}> + <div className="flex min-h-0 flex-1 flex-col gap-5 pb-5"> + {/* Add-member row */} + {onSharedWithChange && ( + <section className="space-y-2"> + <AddUserInput + onAdd={handleAddUser} + validateEmail={validateNewEmail} + busy={busy === "add"} + placeholder="Add by email..." + autoFocus + submitLabel="Add member" + className="bg-white focus-within:bg-white" + /> + {error && ( + <p className="mt-1.5 text-xs text-red-500"> + {error} + </p> + )} + </section> + )} + + <section className="flex min-h-0 flex-1 flex-col"> + <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 */} + {rosterPending ? ( + <div className="min-h-0 flex-1 space-y-1"> + {[1, 2].map((item) => ( + <div + key={item} + className="flex items-center gap-2.5 rounded-lg px-2 py-1.5" + > + <div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-100" /> + <div className="min-w-0 flex-1"> + <div className="h-3 w-40 animate-pulse rounded bg-gray-100" /> + </div> + <div className="h-4 w-12 shrink-0 animate-pulse rounded-full bg-gray-100" /> + </div> + ))} + </div> + ) : roster.length === 0 ? ( + <div className="flex min-h-0 flex-1 items-center justify-center text-sm text-gray-400"> + No one has access yet. + </div> + ) : ( + <ul className="min-h-0 flex-1 space-y-1 overflow-y-auto"> + {roster.map((entry) => { + const entryEmail = entry.email ?? ""; + const rowKey = + entry.email ?? + entry.user_id ?? + `${entry.role}-unknown`; + const isYou = + !!currentUserEmail && + !!entryEmail && + entryEmail.toLowerCase() === + currentUserEmail.toLowerCase(); + const isRemoving = + busy === "remove" && + removingEmail === entryEmail; + const displayName = entry.display_name?.trim(); + const primary = isYou + ? "You" + : displayName || entryEmail || "User"; + const showEmail = + !isYou && !!displayName && !!entryEmail; + const initial = displayName + ?.charAt(0) + .toUpperCase(); + return ( + <li + key={`${entry.role}-${rowKey}`} + className="group relative 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} + {showEmail && ( + <span className="text-gray-400"> + {" "} + · {entry.email} + </span> + )} + </p> + </div> + {entry.role === "owner" && ( + <span className="shrink-0 rounded-full px-2 py-1 text-xs text-gray-400"> + Owner + </span> + )} + {entry.role === "member" && ( + <div + className="relative flex shrink-0 items-center" + data-people-member-menu + > + <span className="rounded-full px-2 py-1 text-xs text-gray-400"> + Member + </span> + {onSharedWithChange && ( + <> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + setMemberMenuEmail( + (current) => + current === + entryEmail + ? null + : entryEmail, + ); + }} + disabled={ + busy !== null + } + title="Member actions" + className={`flex h-6 items-center justify-center overflow-hidden rounded-full text-xs leading-none text-gray-500 transition-all hover:bg-gray-200/70 hover:text-gray-800 disabled:opacity-50 ${ + memberMenuEmail === + entryEmail + ? "w-6 opacity-100" + : "w-0 opacity-0 group-hover:w-6 group-hover:opacity-100" + }`} + > + ··· + </button> + {memberMenuEmail === + entryEmail && ( + <div + className="absolute right-0 top-full z-30 mt-1 min-w-28 overflow-hidden rounded-xl border border-white/70 bg-gray-50/95 p-1 shadow-[0_8px_20px_rgba(15,23,42,0.09),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-2xl" + onClick={( + event, + ) => + event.stopPropagation() + } + > + <button + type="button" + onClick={() => + void handleRemove( + entryEmail, + ) + } + disabled={ + busy !== + null + } + className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50" + > + {isRemoving && ( + <Loader2 className="h-3 w-3 animate-spin" /> + )} + Delete + </button> + </div> + )} + </> + )} + </div> + )} + </li> + ); + })} + </ul> + )} + </section> + </div> + </Modal> + ); +} diff --git a/frontend/src/app/components/modals/ProjectPickerModal.tsx b/frontend/src/app/components/modals/ProjectPickerModal.tsx new file mode 100644 index 0000000..75bec10 --- /dev/null +++ b/frontend/src/app/components/modals/ProjectPickerModal.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState, type ButtonHTMLAttributes, type ReactNode } from "react"; +import { Folder } from "lucide-react"; +import { SearchBar } from "@/app/components/ui/search-bar"; +import type { Project } from "../shared/types"; +import { Modal } from "./Modal"; + +type PrimaryAction = Omit< + ButtonHTMLAttributes<HTMLButtonElement>, + "className" +> & { + label: ReactNode; +}; + +interface Props { + open: boolean; + onClose: () => void; + projects: Project[]; + loading: boolean; + selectedId: string | null; + onSelect: (id: string | null) => void; + breadcrumbs?: ReactNode[]; + primaryAction?: PrimaryAction; +} + +export function ProjectPickerModal({ + open, + onClose, + projects, + loading, + selectedId, + onSelect, + breadcrumbs, + primaryAction, +}: Props) { + const [search, setSearch] = useState(""); + const q = search.toLowerCase().trim(); + const filtered = q + ? projects.filter((p) => p.name.toLowerCase().includes(q)) + : projects; + + return ( + <Modal + open={open} + onClose={onClose} + breadcrumbs={breadcrumbs} + primaryAction={primaryAction} + > + <div className="pt-1 pb-2"> + <SearchBar + value={search} + onValueChange={setSearch} + placeholder="Search projects..." + autoFocus + /> + </div> + <div className="min-h-0 flex-1 overflow-y-auto pb-2"> + {loading ? ( + <div className="space-y-px"> + <div className="flex items-center rounded-md px-2 py-2"> + <div className="h-3 w-14 rounded bg-gray-100 animate-pulse" /> + </div> + {[65, 45, 80, 55, 70].map((w, i) => ( + <div + key={i} + className="flex items-center gap-2 rounded-md 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-100 animate-pulse shrink-0" /> + <div + className="h-3 rounded bg-gray-100 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 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; + const documentCount = + project.document_count ?? + project.documents?.length ?? + 0; + return ( + <button + key={project.id} + onClick={() => + onSelect( + isSelected ? null : project.id, + ) + } + className={`w-full flex rounded-md items-center gap-2 px-2 py-2 text-xs transition-all text-left ${isSelected ? "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 ${isSelected ? "bg-gray-900 border-gray-900" : "border-gray-300"}`} + > + {isSelected && ( + <span className="h-1.5 w-1.5 rounded-sm 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" : "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"> + {documentCount} + </span> + </button> + ); + })} + </div> + </div> + )} + </div> + </Modal> + ); +} diff --git a/frontend/src/app/components/modals/credits-exhausted-modal.tsx b/frontend/src/app/components/modals/credits-exhausted-modal.tsx deleted file mode 100644 index 767761d..0000000 --- a/frontend/src/app/components/modals/credits-exhausted-modal.tsx +++ /dev/null @@ -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, - ); -} diff --git a/frontend/src/app/components/modals/delete-chats-modal.tsx b/frontend/src/app/components/modals/delete-chats-modal.tsx deleted file mode 100644 index 5b6671f..0000000 --- a/frontend/src/app/components/modals/delete-chats-modal.tsx +++ /dev/null @@ -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> - </> - ); -} diff --git a/frontend/src/app/components/modals/simple-link-dialog.tsx b/frontend/src/app/components/modals/simple-link-dialog.tsx deleted file mode 100644 index d3288a9..0000000 --- a/frontend/src/app/components/modals/simple-link-dialog.tsx +++ /dev/null @@ -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, - ); -} diff --git a/frontend/src/app/components/shared/ApiKeyMissingModal.tsx b/frontend/src/app/components/popups/ApiKeyMissingPopup.tsx similarity index 91% rename from frontend/src/app/components/shared/ApiKeyMissingModal.tsx rename to frontend/src/app/components/popups/ApiKeyMissingPopup.tsx index d5f4676..e59aeca 100644 --- a/frontend/src/app/components/shared/ApiKeyMissingModal.tsx +++ b/frontend/src/app/components/popups/ApiKeyMissingPopup.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { AlertTriangle } from "lucide-react"; import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability"; -import { WarningPopup } from "./WarningPopup"; +import { WarningPopup } from "../popups/WarningPopup"; interface Props { open: boolean; @@ -13,7 +13,7 @@ interface Props { message?: string; } -export function ApiKeyMissingModal({ open, onClose, provider, message }: Props) { +export function ApiKeyMissingPopup({ open, onClose, provider, message }: Props) { const router = useRouter(); if (!open) return null; diff --git a/frontend/src/app/components/shared/ConfirmPopup.tsx b/frontend/src/app/components/popups/ConfirmPopup.tsx similarity index 75% rename from frontend/src/app/components/shared/ConfirmPopup.tsx rename to frontend/src/app/components/popups/ConfirmPopup.tsx index 22be684..9201b79 100644 --- a/frontend/src/app/components/shared/ConfirmPopup.tsx +++ b/frontend/src/app/components/popups/ConfirmPopup.tsx @@ -3,7 +3,8 @@ import { createPortal } from "react-dom"; import type { ReactNode } from "react"; import { Loader2, Trash2 } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { PillButton } from "@/app/components/ui/pill-button"; +import { cn } from "@/app/lib/utils"; type ConfirmStatus = "idle" | "loading" | "complete"; @@ -76,27 +77,23 @@ export function ConfirmPopup({ </div> )} <div className="mt-3 flex items-center justify-end gap-2"> - <button - type="button" + <PillButton + tone="white" + size="sm" 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" + </PillButton> + <PillButton + tone={isDeleteAction ? "danger" : "black"} + size="sm" 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", - )} + className="h-7 px-3.5 leading-none" aria-busy={confirmBusy} > {resolvedConfirmLabel} - </button> + </PillButton> </div> </div> </div>, diff --git a/frontend/src/app/components/shared/MfaVerificationPopup.tsx b/frontend/src/app/components/popups/MfaVerificationPopup.tsx similarity index 97% rename from frontend/src/app/components/shared/MfaVerificationPopup.tsx rename to frontend/src/app/components/popups/MfaVerificationPopup.tsx index 2bd3e0e..8916779 100644 --- a/frontend/src/app/components/shared/MfaVerificationPopup.tsx +++ b/frontend/src/app/components/popups/MfaVerificationPopup.tsx @@ -8,8 +8,8 @@ import { type KeyboardEvent, } from "react"; import { Loader2 } from "lucide-react"; -import { supabase } from "@/lib/supabase"; -import { Modal } from "@/app/components/shared/Modal"; +import { supabase } from "@/app/lib/supabase"; +import { Modal } from "../modals/Modal"; type MfaFactor = { id: string; @@ -125,7 +125,7 @@ export function MfaVerificationPopup({ <Modal open={open} onClose={onCancel} - title={title} + breadcrumbs={[title]} size="sm" className="h-auto min-h-[310px] max-h-[min(92vh,400px)]" cancelAction={{ @@ -146,7 +146,7 @@ export function MfaVerificationPopup({ disabled: !canVerify, }} > - <div className="space-y-5 pb-2 pt-0"> + <div className="min-h-0 flex-1 space-y-5 overflow-y-auto 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"> diff --git a/frontend/src/app/components/shared/OwnerOnlyModal.tsx b/frontend/src/app/components/popups/OwnerOnlyPopup.tsx similarity index 90% rename from frontend/src/app/components/shared/OwnerOnlyModal.tsx rename to frontend/src/app/components/popups/OwnerOnlyPopup.tsx index 48c606c..fa41bed 100644 --- a/frontend/src/app/components/shared/OwnerOnlyModal.tsx +++ b/frontend/src/app/components/popups/OwnerOnlyPopup.tsx @@ -1,7 +1,7 @@ "use client"; import { Lock } from "lucide-react"; -import { WarningPopup } from "./WarningPopup"; +import { WarningPopup } from "../popups/WarningPopup"; interface Props { open: boolean; @@ -17,12 +17,12 @@ interface Props { } /** - * Lightweight "you don't have permission" modal shown when a non-owner + * Lightweight "you don't have permission" popup 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({ +export function OwnerOnlyPopup({ open, onClose, title = "Owner-only action", diff --git a/frontend/src/app/components/shared/WarningPopup.tsx b/frontend/src/app/components/popups/WarningPopup.tsx similarity index 98% rename from frontend/src/app/components/shared/WarningPopup.tsx rename to frontend/src/app/components/popups/WarningPopup.tsx index eb6e238..8a50f50 100644 --- a/frontend/src/app/components/shared/WarningPopup.tsx +++ b/frontend/src/app/components/popups/WarningPopup.tsx @@ -3,7 +3,7 @@ import { createPortal } from "react-dom"; import type { ReactNode } from "react"; import { AlertCircle, X } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; interface WarningPopupAction { label: ReactNode; diff --git a/frontend/src/app/components/projects/DocumentSidePanel.tsx b/frontend/src/app/components/projects/DocumentSidePanel.tsx index 2866734..1936a5d 100644 --- a/frontend/src/app/components/projects/DocumentSidePanel.tsx +++ b/frontend/src/app/components/projects/DocumentSidePanel.tsx @@ -12,12 +12,14 @@ 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 { cn } from "@/app/lib/utils"; import { formatBytes } from "./ProjectPageParts"; const MIN_DOC_COLUMN_WIDTH = 420; @@ -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,23 @@ 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} + /> + ) : ( + <PdfView + key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`} + doc={{ + document_id: doc.id, + version_id: selectedVersionId, + }} + rounded={false} + /> + )} </div> </section> diff --git a/frontend/src/app/components/projects/NewProjectModal.tsx b/frontend/src/app/components/projects/NewProjectModal.tsx index cc2a76e..e592500 100644 --- a/frontend/src/app/components/projects/NewProjectModal.tsx +++ b/frontend/src/app/components/projects/NewProjectModal.tsx @@ -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 { useAuth } from "@/contexts/AuthContext"; -import { Modal } from "../shared/Modal"; +import type { UserLookupResult } from "@/app/lib/mikeApi"; +import { useAuth } from "@/app/contexts/AuthContext"; +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" : "Add Documents", + ]} + secondaryAction={ + step === "documents" + ? { + label: `Upload${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,125 @@ 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="flex min-h-0 flex-1 flex-col"> + <FileDirectory + standaloneDocs={standaloneDocuments} + directoryProjects={dirProjects} + loading={dirLoading} + selectedIds={selectedDocIds} + onChange={setSelectedDocIds} + emptyMessage="No existing documents" + searchable + searchAutoFocus + showProjectTabs /> </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> )} diff --git a/frontend/src/app/components/projects/ProjectAssistantTable.tsx b/frontend/src/app/components/projects/ProjectAssistantTable.tsx index dccb100..aa89177 100644 --- a/frontend/src/app/components/projects/ProjectAssistantTable.tsx +++ b/frontend/src/app/components/projects/ProjectAssistantTable.tsx @@ -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 ? ( diff --git a/frontend/src/app/components/projects/ProjectDetailsModal.tsx b/frontend/src/app/components/projects/ProjectDetailsModal.tsx index 1fbb2ea..5b7396a 100644 --- a/frontend/src/app/components/projects/ProjectDetailsModal.tsx +++ b/frontend/src/app/components/projects/ProjectDetailsModal.tsx @@ -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> - ); -} diff --git a/frontend/src/app/components/projects/ProjectDocumentsView.tsx b/frontend/src/app/components/projects/ProjectDocumentsView.tsx index 586350f..264fe7a 100644 --- a/frontend/src/app/components/projects/ProjectDocumentsView.tsx +++ b/frontend/src/app/components/projects/ProjectDocumentsView.tsx @@ -43,10 +43,10 @@ import { import { AddDocumentsModal, invalidateDirectoryCache, -} from "@/app/components/shared/AddDocumentsModal"; -import { useAuth } from "@/contexts/AuthContext"; -import { WarningPopup } from "@/app/components/shared/WarningPopup"; -import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup"; +} from "@/app/components/modals/AddDocumentsModal"; +import { useAuth } from "@/app/contexts/AuthContext"; +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> ) : ( diff --git a/frontend/src/app/components/projects/ProjectExplorer.tsx b/frontend/src/app/components/projects/ProjectExplorer.tsx index 1f2d389..4b1cdd3 100644 --- a/frontend/src/app/components/projects/ProjectExplorer.tsx +++ b/frontend/src/app/components/projects/ProjectExplorer.tsx @@ -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 = { diff --git a/frontend/src/app/components/projects/ProjectPageParts.tsx b/frontend/src/app/components/projects/ProjectPageParts.tsx index 6037653..594fdc8 100644 --- a/frontend/src/app/components/projects/ProjectPageParts.tsx +++ b/frontend/src/app/components/projects/ProjectPageParts.tsx @@ -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, }, { diff --git a/frontend/src/app/components/projects/ProjectPracticeField.tsx b/frontend/src/app/components/projects/ProjectPracticeField.tsx new file mode 100644 index 0000000..755adee --- /dev/null +++ b/frontend/src/app/components/projects/ProjectPracticeField.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useMemo } from "react"; +import { PRACTICE_OPTIONS } from "../workflows/practices"; +import { ModalSelect, type ModalSelectOption } from "../modals/ModalSelect"; +import { ModalTextInput } from "../modals/ModalTextInput"; + +const OPTION_NONE = "__none__"; +const OPTION_OTHER = "Other"; + +interface ProjectPracticeFieldProps { + id: string; + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +export function ProjectPracticeField({ + id, + value, + onChange, + disabled = false, +}: ProjectPracticeFieldProps) { + const selectedOption = useMemo(() => { + if (!value.trim()) return OPTION_NONE; + return (PRACTICE_OPTIONS as readonly string[]).includes(value) + ? value + : OPTION_OTHER; + }, [value]); + const customValue = + selectedOption === OPTION_OTHER && value !== OPTION_OTHER ? value : ""; + const options = useMemo<ModalSelectOption[]>( + () => [ + { value: OPTION_NONE, label: "None" }, + ...PRACTICE_OPTIONS, + ], + [], + ); + + function handleSelect(option: string) { + if (option === OPTION_NONE) { + onChange(""); + return; + } + if (option === OPTION_OTHER) { + onChange(OPTION_OTHER); + return; + } + onChange(option); + } + + return ( + <div className="space-y-2"> + <ModalSelect + id={id} + value={selectedOption} + options={options} + onChange={handleSelect} + placeholder="Select practice" + disabled={disabled} + /> + {selectedOption === OPTION_OTHER && ( + <ModalTextInput + type="text" + value={customValue} + onChange={(event) => onChange(event.target.value)} + placeholder="Enter practice..." + disabled={disabled} + /> + )} + </div> + ); +} diff --git a/frontend/src/app/components/projects/ProjectReviewsTable.tsx b/frontend/src/app/components/projects/ProjectReviewsTable.tsx index 8cf1742..7f53fe7 100644 --- a/frontend/src/app/components/projects/ProjectReviewsTable.tsx +++ b/frontend/src/app/components/projects/ProjectReviewsTable.tsx @@ -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)} /> diff --git a/frontend/src/app/components/projects/ProjectWorkspace.tsx b/frontend/src/app/components/projects/ProjectWorkspace.tsx index 8a212ea..f751384 100644 --- a/frontend/src/app/components/projects/ProjectWorkspace.tsx +++ b/frontend/src/app/components/projects/ProjectWorkspace.tsx @@ -29,13 +29,13 @@ 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"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { ProjectDetailsModal } from "./ProjectDetailsModal"; import { ProjectPageHeader, @@ -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={() => { diff --git a/frontend/src/app/components/projects/ProjectsOverview.tsx b/frontend/src/app/components/projects/ProjectsOverview.tsx index 24c3f0a..9c08f77 100644 --- a/frontend/src/app/components/projects/ProjectsOverview.tsx +++ b/frontend/src/app/components/projects/ProjectsOverview.tsx @@ -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 { useAuth } from "@/contexts/AuthContext"; +import { + listProjects, + updateProject, + deleteProject, +} from "@/app/lib/mikeApi"; +import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; +import { useAuth } from "@/app/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)} diff --git a/frontend/src/components/providers.tsx b/frontend/src/app/components/providers.tsx similarity index 84% rename from frontend/src/components/providers.tsx rename to frontend/src/app/components/providers.tsx index 8d00463..039934b 100644 --- a/frontend/src/components/providers.tsx +++ b/frontend/src/app/components/providers.tsx @@ -1,8 +1,8 @@ "use client"; import { Suspense } from "react"; -import { AuthProvider } from "@/contexts/AuthContext"; -import { UserProfileProvider } from "@/contexts/UserProfileContext"; +import { AuthProvider } from "@/app/contexts/AuthContext"; +import { UserProfileProvider } from "@/app/contexts/UserProfileContext"; import { MfaLoginGate } from "@/app/components/shared/MfaLoginGate"; export function Providers({ children }: { children: React.ReactNode }) { diff --git a/frontend/src/app/components/shared/AddDocumentsModal.tsx b/frontend/src/app/components/shared/AddDocumentsModal.tsx deleted file mode 100644 index b87caac..0000000 --- a/frontend/src/app/components/shared/AddDocumentsModal.tsx +++ /dev/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)} - /> - </> - ); -} diff --git a/frontend/src/app/components/shared/AddUserInput.tsx b/frontend/src/app/components/shared/AddUserInput.tsx new file mode 100644 index 0000000..cd5aa3c --- /dev/null +++ b/frontend/src/app/components/shared/AddUserInput.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import type { KeyboardEvent } from "react"; +import { Loader2, UserPlus } from "lucide-react"; +import { + lookupUserByEmail, + type UserLookupResult, +} from "@/app/lib/mikeApi"; +import { cn } from "@/app/lib/utils"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface AddUserInputProps { + onAdd: (user: UserLookupResult) => Promise<void> | void; + validateEmail?: (email: string) => Promise<string | null> | string | null; + busy?: boolean; + placeholder?: string; + autoFocus?: boolean; + submitLabel?: string; + className?: string; +} + +export function AddUserInput({ + onAdd, + validateEmail, + busy = false, + placeholder = "Add by email...", + autoFocus = false, + submitLabel = "Add user", + className, +}: AddUserInputProps) { + const [input, setInput] = useState(""); + const [checking, setChecking] = useState(false); + const [error, setError] = useState<string | null>(null); + + const trimmedEmail = input.trim().toLowerCase(); + const showAddButton = trimmedEmail.length > 0; + + async function commitUser() { + const email = trimmedEmail; + if (!email || busy || checking) return; + if (!EMAIL_RE.test(email)) { + setError("Enter a valid email."); + return; + } + + setError(null); + setChecking(true); + try { + const validationError = await validateEmail?.(email); + if (validationError) { + setError(validationError); + return; + } + + const user = await lookupUserByEmail(email); + if (!user.exists) { + setError(`${email} does not belong to a Mike user.`); + return; + } + + await onAdd(user); + setInput(""); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Could not add this user. Try again.", + ); + } finally { + setChecking(false); + } + } + + function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + void commitUser(); + } + } + + return ( + <div> + <div + className={cn( + "flex min-h-10 items-center gap-2 rounded-xl border border-white/70 bg-white/55 px-3 py-1.5 shadow-[0_3px_9px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors focus-within:bg-white/70", + className, + )} + > + <UserPlus className="h-3.5 w-3.5 shrink-0 text-gray-400" /> + <input + type="email" + value={input} + onChange={(event) => { + setInput(event.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="min-w-0 flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400" + autoFocus={autoFocus} + /> + {showAddButton && ( + <button + type="button" + onMouseDown={(event) => event.preventDefault()} + onClick={() => void commitUser()} + disabled={busy || checking} + title={submitLabel} + className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-blue-500/35 bg-blue-600/90 px-2.5 text-[11px] font-medium leading-none text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(255,255,255,0.16),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40" + > + {(busy || checking) && ( + <Loader2 className="h-3 w-3 animate-spin" /> + )} + Add + </button> + )} + </div> + {error && <p className="mt-1.5 text-xs text-red-500">{error}</p>} + </div> + ); +} diff --git a/frontend/src/app/components/shared/AppSidebar.tsx b/frontend/src/app/components/shared/AppSidebar.tsx index 6b9f7d8..9fb96be 100644 --- a/frontend/src/app/components/shared/AppSidebar.tsx +++ b/frontend/src/app/components/shared/AppSidebar.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react"; import { PanelLeft, MessageSquare, + Folder, FolderOpen, Table2, Library, @@ -11,16 +12,16 @@ import { ChevronsUpDown, ChevronDown, } from "lucide-react"; -import { useAuth } from "@/contexts/AuthContext"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; import { useRouter, usePathname } from "next/navigation"; import Link from "next/link"; -import { MikeIcon } from "@/components/chat/mike-icon"; +import { MikeIcon } from "@/app/components/chat/mike-icon"; import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem"; import { listProjects } from "@/app/lib/mikeApi"; import type { Project } from "@/app/components/shared/types"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; const NAV_ITEMS = [ { href: "/assistant", label: "Assistant", icon: MessageSquare }, @@ -85,9 +86,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { }); }, [user]); - useEffect(() => { - if (!isOpen) setShouldAnimate(true); - }, [isOpen]); + const handleToggle = () => { + if (isOpen) setShouldAnimate(true); + onToggle(); + }; useEffect(() => { const handleClickOutside = () => setIsDropdownOpen(false); @@ -121,113 +123,222 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { if (!user) return null; return ( - <div - className={cn( - 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", - "flex flex-col transition-all duration-300 absolute md:relative z-[99]", + <> + {/* Mobile: tapping outside the expanded sidebar closes it. The + sidebar (z-[99]) sits above this scrim (z-[98]); md+ is + unaffected since the sidebar is part of the layout there. */} + {isOpen && ( + <div + className="fixed inset-0 z-[98] bg-gray-300/20 md:hidden" + onClick={handleToggle} + aria-hidden="true" + /> )} - > - {/* Toggle + Logo */} <div - className={`items-center justify-between px-2.5 py-3 ${ - !isOpen ? "hidden md:flex" : "flex" - }`} + className={cn( + 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_-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]", + )} > + {/* Toggle + Logo */} + <div + className={`items-center justify-between px-2.5 py-3 ${ + !isOpen ? "hidden md:flex" : "flex" + }`} + > + {isOpen && ( + <div className="px-2"> + <Link + href="/assistant" + className="flex items-center gap-1.5 hover:opacity-80 transition-opacity" + > + <MikeIcon size={22} /> + <span + className={`text-2xl font-light font-serif ${ + shouldAnimate ? "sidebar-fade-in" : "" + }`} + > + Mike + </span> + </Link> + </div> + )} + <button + onClick={handleToggle} + className={cn( + "h-9 w-9 p-2.5 items-center flex transition-colors", + "rounded-md hover:bg-gray-100", + )} + title={isOpen ? "Close sidebar" : "Open sidebar"} + > + <PanelLeft className="h-4 w-4" /> + </button> + </div> + + {/* Nav items */} + {NAV_ITEMS.map(({ href, label, icon: Icon }) => { + const isActive = + href === "/assistant" + ? pathname === href + : href === "/projects" + ? pathname === href + : pathname === href || + pathname.startsWith(href + "/"); + return ( + <div key={href} className="py-0.5 px-2.5"> + <button + onClick={() => router.push(href)} + title={!isOpen ? label : ""} + className={cn( + "w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left", + isActive + ? "bg-gray-200/60 text-gray-900" + : "text-gray-700 hover:bg-gray-100", + !isOpen ? "hidden md:flex" : "flex", + )} + > + <Icon + className={`h-4 w-4 flex-shrink-0 ${ + isActive + ? "text-gray-900" + : "text-black" + }`} + /> + {isOpen && ( + <span + className={`text-sm font-medium ${ + shouldAnimate + ? "sidebar-fade-in-2" + : "" + }`} + > + {label} + </span> + )} + </button> + </div> + ); + })} + {isOpen && ( - <div className="px-2"> - <Link - href="/assistant" - className="flex items-center gap-1.5 hover:opacity-80 transition-opacity" - > - <MikeIcon size={22} /> - <span - className={`text-2xl font-light font-serif ${ + <div className="mt-4 flex-1 min-h-0 flex flex-col gap-4"> + {/* Recent Projects */} + <div> + <button + onClick={() => setProjectsCollapsed((v) => !v)} + className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${ shouldAnimate ? "sidebar-fade-in" : "" }`} > - Mike - </span> - </Link> - </div> - )} - <button - onClick={onToggle} - className={cn( - "h-9 w-9 p-2.5 items-center flex transition-colors", - "rounded-md hover:bg-gray-100", - )} - title={isOpen ? "Close sidebar" : "Open sidebar"} - > - <PanelLeft className="h-4 w-4" /> - </button> - </div> - - {/* Nav items */} - {NAV_ITEMS.map(({ href, label, icon: Icon }) => { - const isActive = - href === "/assistant" - ? pathname === href - : href === "/projects" - ? pathname === href - : pathname === href || - pathname.startsWith(href + "/"); - return ( - <div key={href} className="py-0.5 px-2.5"> - <button - onClick={() => router.push(href)} - title={!isOpen ? label : ""} - className={cn( - "w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left", - isActive - ? "bg-gray-200/60 text-gray-900" - : "text-gray-700 hover:bg-gray-100", - !isOpen ? "hidden md:flex" : "flex", - )} - > - <Icon - className={`h-4 w-4 flex-shrink-0 ${ - isActive ? "text-gray-900" : "text-black" - }`} - /> - {isOpen && ( - <span - className={`text-sm font-medium ${ - shouldAnimate ? "sidebar-fade-in-2" : "" + <span>Recent Projects</span> + <ChevronDown + className={`h-3.5 w-3.5 transition-transform ${ + projectsCollapsed ? "-rotate-90" : "" }`} - > - {label} - </span> + /> + </button> + {!projectsCollapsed && ( + <> + {!recentProjects ? ( + <div className="space-y-1 px-2.5"> + {[50, 65, 45].map((w, i) => ( + <div + key={i} + className="h-9 flex items-center px-3 rounded-md" + > + <div + className="h-3 bg-gray-200 rounded animate-pulse" + style={{ + width: `${w}%`, + }} + /> + </div> + ))} + </div> + ) : recentProjects.length === 0 ? ( + <div + className={`px-5 py-2 text-xs text-gray-500 ${ + shouldAnimate + ? "sidebar-fade-in-2" + : "" + }`} + > + No projects yet + </div> + ) : ( + <div + className={`space-y-1 px-2.5 ${ + shouldAnimate + ? "sidebar-fade-in-2" + : "" + }`} + > + {recentProjects.map((project) => { + const isActive = + pathname === + `/projects/${project.id}` || + pathname.startsWith( + `/projects/${project.id}/`, + ); + return ( + <button + key={project.id} + onClick={() => + router.push( + `/projects/${project.id}`, + ) + } + title={project.name} + className={cn( + "flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors", + isActive + ? "bg-gray-200/60 text-gray-900" + : "text-gray-700 hover:bg-gray-100", + )} + > + {isActive ? ( + <FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" /> + ) : ( + <Folder className="h-3.5 w-3.5 shrink-0 text-gray-600" /> + )} + <span className="min-w-0 flex-1 truncate"> + {project.name} + </span> + </button> + ); + })} + </div> + )} + </> )} - </button> - </div> - ); - })} + </div> - {isOpen && ( - <div className="mt-4 flex-1 min-h-0 flex flex-col gap-4"> - {/* Recent Projects */} - <div> - <button - onClick={() => setProjectsCollapsed((v) => !v)} - className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${ - shouldAnimate ? "sidebar-fade-in" : "" - }`} - > - <span>Recent Projects</span> - <ChevronDown - className={`h-3.5 w-3.5 transition-transform ${ - projectsCollapsed ? "-rotate-90" : "" + {/* Assistant History */} + <div className="flex min-h-0 flex-1 flex-col"> + <button + onClick={() => setHistoryCollapsed((v) => !v)} + className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${ + shouldAnimate ? "sidebar-fade-in" : "" }`} - /> - </button> - {!projectsCollapsed && ( - <> - {!recentProjects ? ( + > + <span>Assistant History</span> + <ChevronDown + className={`h-3.5 w-3.5 transition-transform ${ + historyCollapsed ? "-rotate-90" : "" + }`} + /> + </button> + <div + className={`overflow-y-auto flex-1 ${ + historyCollapsed ? "hidden" : "" + }`} + > + {!chats ? ( <div className="space-y-1 px-2.5"> - {[50, 65, 45].map((w, i) => ( + {[40, 60, 50, 70, 45].map((w, i) => ( <div key={i} className="h-9 flex items-center px-3 rounded-md" @@ -239,220 +350,142 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { </div> ))} </div> - ) : recentProjects.length === 0 ? ( + ) : chats.length === 0 ? ( <div - className={`px-5 py-2 text-xs text-gray-500 ${ + className={`text-xs text-gray-500 py-2 px-5 ${ shouldAnimate ? "sidebar-fade-in-2" : "" }`} > - No projects yet + No chats yet </div> ) : ( - <div - className={`space-y-1 px-2.5 ${ - shouldAnimate - ? "sidebar-fade-in-2" - : "" - }`} - > - {recentProjects.map((project) => { - const isActive = - pathname === - `/projects/${project.id}` || - pathname.startsWith( - `/projects/${project.id}/`, - ); - return ( - <button - key={project.id} - onClick={() => - router.push( - `/projects/${project.id}`, - ) + <> + <div + className={`space-y-1 px-2.5 ${ + shouldAnimate + ? "sidebar-fade-in-2" + : "" + }`} + > + {chats.map((chat) => ( + <SidebarChatItem + key={chat.id} + chat={chat} + isActive={ + routeChatId === chat.id } - title={project.name} + projectName={ + chat.project_id + ? projectNames[ + chat + .project_id + ] + : undefined + } + onSelect={() => { + setCurrentChatId( + chat.id, + ); + router.push( + chat.project_id + ? `/projects/${chat.project_id}/assistant/chat/${chat.id}` + : `/assistant/chat/${chat.id}`, + ); + }} + /> + ))} + </div> + {hasMoreChats && ( + <div className="px-2.5 pt-1"> + <button + onClick={loadMoreChats} className={cn( - "flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors", - isActive - ? "bg-gray-200/60 text-gray-900" - : "text-gray-700 hover:bg-gray-100", + "flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700", + "hover:bg-gray-100", )} > - <FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" /> - <span className="min-w-0 flex-1 truncate"> - {project.name} - </span> + Load more </button> - ); - })} - </div> + </div> + )} + </> )} - </> - )} + </div> + </div> </div> + )} - {/* Assistant History */} - <div className="flex min-h-0 flex-1 flex-col"> - <button - onClick={() => setHistoryCollapsed((v) => !v)} - className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${ - shouldAnimate ? "sidebar-fade-in" : "" - }`} - > - <span>Assistant History</span> - <ChevronDown - className={`h-3.5 w-3.5 transition-transform ${ - historyCollapsed ? "-rotate-90" : "" - }`} - /> - </button> - <div - className={`overflow-y-auto flex-1 ${ - historyCollapsed ? "hidden" : "" - }`} - > - {!chats ? ( - <div className="space-y-1 px-2.5"> - {[40, 60, 50, 70, 45].map((w, i) => ( - <div - key={i} - className="h-9 flex items-center px-3 rounded-md" - > - <div - className="h-3 bg-gray-200 rounded animate-pulse" - style={{ width: `${w}%` }} - /> - </div> - ))} + {/* User Profile */} + <div className="mt-auto p-1"> + {user && ( + <div className="relative"> + <button + onClick={() => + setIsDropdownOpen(!isDropdownOpen) + } + className={cn( + "flex items-center transition-colors w-full px-2.5 py-3 border-t", + "rounded-xl border-white/60", + !isOpen ? "hidden md:flex" : "", + pathname === "/account" || isDropdownOpen + ? "bg-gray-200/60" + : "hover:bg-gray-100", + )} + title={!isOpen ? user.email : undefined} + > + <div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif"> + {getUserInitials(user.email)} </div> - ) : chats.length === 0 ? ( - <div - className={`text-xs text-gray-500 py-2 px-5 ${ - shouldAnimate ? "sidebar-fade-in-2" : "" - }`} - > - No chats yet - </div> - ) : ( - <> + {isOpen && ( <div - className={`space-y-1 px-2.5 ${ + className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${ shouldAnimate ? "sidebar-fade-in-2" : "" }`} > - {chats.map((chat) => ( - <SidebarChatItem - key={chat.id} - chat={chat} - isActive={ - routeChatId === chat.id - } - projectName={ - chat.project_id - ? projectNames[ - chat.project_id - ] - : undefined - } - onSelect={() => { - setCurrentChatId(chat.id); - router.push( - chat.project_id - ? `/projects/${chat.project_id}/assistant/chat/${chat.id}` - : `/assistant/chat/${chat.id}`, - ); - }} - /> - ))} - </div> - {hasMoreChats && ( - <div className="px-2.5 pt-1"> - <button - onClick={loadMoreChats} - className={cn( - "flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700", - "hover:bg-gray-100", - )} - > - Load more - </button> + <div className="flex flex-col gap-0.5 min-w-0"> + <div className="text-sm font-medium text-gray-900 leading-none"> + {getDisplayName()} + </div> + <div className="text-[12px] text-gray-500 leading-none"> + {getUserTier()} + </div> </div> - )} - </> - )} - </div> - </div> - </div> - )} + <ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" /> + </div> + )} + </button> - {/* User Profile */} - <div className="mt-auto p-1"> - {user && ( - <div className="relative"> - <button - onClick={() => setIsDropdownOpen(!isDropdownOpen)} - className={cn( - "flex items-center transition-colors w-full px-2.5 py-3 border-t", - "rounded-xl border-white/60", - !isOpen ? "hidden md:flex" : "", - pathname === "/account" || isDropdownOpen - ? "bg-gray-200/60" - : "hover:bg-gray-100", - )} - title={!isOpen ? user.email : undefined} - > - <div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif"> - {getUserInitials(user.email)} - </div> - {isOpen && ( + {isDropdownOpen && ( <div - className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${ - shouldAnimate ? "sidebar-fade-in-2" : "" - }`} + className={cn( + "absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap", + isOpen ? "right-0" : "w-56", + "bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl", + )} > - <div className="flex flex-col gap-0.5 min-w-0"> - <div className="text-sm font-medium text-gray-900 leading-none"> - {getDisplayName()} - </div> - <div className="text-[12px] text-gray-500 leading-none"> - {getUserTier()} - </div> - </div> - <ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" /> + <button + onClick={() => { + router.push("/account"); + setIsDropdownOpen(false); + }} + className={cn( + "w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md", + "hover:bg-white/70", + )} + > + <User className="h-4 w-4" /> + Account Settings + </button> </div> )} - </button> - - {isDropdownOpen && ( - <div - className={cn( - "absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap", - isOpen ? "right-0" : "w-56", - "bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl", - )} - > - <button - onClick={() => { - router.push("/account"); - setIsDropdownOpen(false); - }} - className={cn( - "w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md", - "hover:bg-white/70", - )} - > - <User className="h-4 w-4" /> - Account Settings - </button> - </div> - )} - </div> - )} + </div> + )} + </div> </div> - </div> + </> ); } diff --git a/frontend/src/app/components/shared/DocViewModal.tsx b/frontend/src/app/components/shared/DocViewModal.tsx deleted file mode 100644 index 0fb57e7..0000000 --- a/frontend/src/app/components/shared/DocViewModal.tsx +++ /dev/null @@ -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, - ); -} diff --git a/frontend/src/app/components/shared/DocumentCard.tsx b/frontend/src/app/components/shared/DocumentCard.tsx deleted file mode 100644 index abd01c6..0000000 --- a/frontend/src/app/components/shared/DocumentCard.tsx +++ /dev/null @@ -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> - ); -} diff --git a/frontend/src/app/components/shared/EmailPillInput.tsx b/frontend/src/app/components/shared/EmailPillInput.tsx deleted file mode 100644 index a74e158..0000000 --- a/frontend/src/app/components/shared/EmailPillInput.tsx +++ /dev/null @@ -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> - ); -} diff --git a/frontend/src/app/components/shared/FileDirectory.tsx b/frontend/src/app/components/shared/FileDirectory.tsx index ae81817..d4ee15a 100644 --- a/frontend/src/app/components/shared/FileDirectory.tsx +++ b/frontend/src/app/components/shared/FileDirectory.tsx @@ -5,14 +5,15 @@ import { Check, ChevronDown, ChevronRight, - File, - FileText, Folder, - Trash2, + FolderOpen, Loader2, } from "lucide-react"; import type { Document, Project } from "./types"; import { VersionChip } from "./VersionChip"; +import { FileTypeIcon } from "./FileTypeIcon"; +import { SearchBar } from "@/app/components/ui/search-bar"; +import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle"; 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 { @@ -38,9 +37,12 @@ interface FileDirectoryProps { allowMultiple?: boolean; forceExpanded?: boolean; emptyMessage?: string; - heading?: string; - onDelete?: (ids: string[]) => void | Promise<void>; uploadingFilenames?: string[]; + searchable?: boolean; + searchPlaceholder?: string; + searchAutoFocus?: boolean; + searchNoResultsMessage?: string; + showProjectTabs?: boolean; } export function FileDirectory({ @@ -52,90 +54,126 @@ export function FileDirectory({ allowMultiple = true, forceExpanded = false, emptyMessage = "No documents yet", - heading = "Documents", - onDelete, uploadingFilenames = [], + searchable = false, + searchPlaceholder = "Search...", + searchAutoFocus = false, + searchNoResultsMessage = "No matches found", + showProjectTabs, }: FileDirectoryProps) { const [expandedProjects, setExpandedProjects] = useState<Set<string>>( new Set(), ); - const [deleting, setDeleting] = useState(false); + const [selectedTab, setSelectedTab] = useState<"files" | "projects">( + "files", + ); + const [search, setSearch] = useState(""); - const selectedCount = selectedIds.size; - - async function handleDelete() { - if (!onDelete || selectedCount === 0 || deleting) return; - const ids = Array.from(selectedIds); - setDeleting(true); - try { - await onDelete(ids); - const next = new Set(selectedIds); - ids.forEach((id) => next.delete(id)); - onChange(next); - } finally { - setDeleting(false); - } - } + 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; + const showTabs = showProjectTabs ?? directoryProjects.length > 0; + const activeTab = showTabs ? selectedTab : "files"; + const hasVisibleFiles = + visibleStandaloneDocs.length > 0 || + visibleUploadingFilenames.length > 0; + const hasVisibleProjects = visibleDirectoryProjects.length > 0; + const activeTabHasNoResults = + q && + ((activeTab === "files" && !hasVisibleFiles) || + (activeTab === "projects" && !hasVisibleProjects)); const allDocs = [ ...standaloneDocs, ...directoryProjects.flatMap((p) => p.documents ?? []), ]; - const allStandaloneSelected = - standaloneDocs.length > 0 && - standaloneDocs.every((d) => selectedIds.has(d.id)); - function toggle(docId: string) { if (!allowMultiple) { onChange(new Set([docId])); return; } const next = new Set(selectedIds); - next.has(docId) ? next.delete(docId) : next.add(docId); - onChange(next); - } - - function toggleAll() { - if (allStandaloneSelected) { - const next = new Set(selectedIds); - standaloneDocs.forEach((d) => next.delete(d.id)); - onChange(next); + if (next.has(docId)) { + next.delete(docId); } else { - const next = new Set(selectedIds); - standaloneDocs.forEach((d) => next.add(d.id)); - onChange(next); + next.add(docId); } + onChange(next); } function toggleFolder(projectId: string) { 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"> - {/* 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> - {/* File rows skeleton */} - <div> + <div className="flex min-h-0 flex-1 flex-col space-y-2"> + {searchable && ( + <SearchBar + value={search} + onValueChange={setSearch} + placeholder={searchPlaceholder} + autoFocus={searchAutoFocus} + wrapperClassName={showTabs ? "mb-4" : "mb-3"} + /> + )} + {showTabs && ( + <FileDirectoryTabs + activeTab={activeTab} + onChange={setSelectedTab} + /> + )} + <div className="min-h-0 flex-1 overflow-y-auto"> {[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 +189,269 @@ export function FileDirectory({ uploadingFilenames.length === 0 ) { return ( - <p className="text-center text-sm text-gray-400 py-8"> - {emptyMessage} - </p> + <div className="flex min-h-0 flex-1 flex-col space-y-2"> + {searchable && ( + <SearchBar + value={search} + onValueChange={setSearch} + placeholder={searchPlaceholder} + autoFocus={searchAutoFocus} + wrapperClassName={showTabs ? "mb-4" : "mb-3"} + /> + )} + {showTabs && ( + <FileDirectoryTabs + activeTab={activeTab} + onChange={setSelectedTab} + /> + )} + <div className="min-h-0 flex-1 overflow-y-auto"> + <p className="text-center text-sm text-gray-400 py-8"> + {emptyMessage} + </p> + </div> + </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" + <div className="flex min-h-0 flex-1 flex-col space-y-2 rounded-sm"> + {searchable && ( + <SearchBar + value={search} + onValueChange={setSearch} + placeholder={searchPlaceholder} + autoFocus={searchAutoFocus} + wrapperClassName={showTabs ? "mb-4" : "mb-3"} + /> + )} + {showTabs && ( + <FileDirectoryTabs + activeTab={activeTab} + onChange={setSelectedTab} + /> + )} + {activeTabHasNoResults ? ( + <div className="min-h-0 flex-1 overflow-y-auto"> + <p className="text-center text-sm text-gray-400 py-8"> + {searchNoResultsMessage} + </p> + </div> + ) : ( + <div className="min-h-0 flex-1 overflow-y-auto"> + {activeTab === "files" && ( + <> + {visibleUploadingFilenames.map((filename) => ( + <div + key={`uploading-${filename}`} + className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left" > - <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" /> - )} - </span> - <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> - ); - })} - - {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}> - <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" - > - {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 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> + ))} + {visibleStandaloneDocs.map((doc) => { + const selected = selectedIds.has(doc.id); + return ( + <button + type="button" + 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" + }`} + > + <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> - <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) - } - 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 - } - /> - {doc.created_at && ( - <span className="shrink-0 text-gray-300"> - {formatDate( - doc.created_at, + <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> + ); + })} + {!q && + visibleStandaloneDocs.length === 0 && + visibleUploadingFilenames.length === 0 && ( + <p className="text-center text-sm text-gray-400 py-8"> + {emptyMessage} + </p> + )} + </> + )} + + {activeTab === "projects" && + 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" /> + ) : ( + <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) + } + 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> + <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> )} - </span> - )} - </button> - ); - }) + </button> + ); + }) + )} + </div> )} </div> - )} - </div> - ); - })} - </div> + ); + })} + {activeTab === "projects" && + !q && + visibleDirectoryProjects.length === 0 && ( + <p className="text-center text-sm text-gray-400 py-8"> + No projects yet + </p> + )} + </div> + )} </div> ); } + +function FileDirectoryTabs({ + activeTab, + onChange, +}: { + activeTab: "files" | "projects"; + onChange: (tab: "files" | "projects") => void; +}) { + return ( + <ModalSegmentedToggle + value={activeTab} + onChange={onChange} + options={[ + { value: "files", label: "Files" }, + { value: "projects", label: "Projects" }, + ]} + size="sm" + className="self-start" + /> + ); +} diff --git a/frontend/src/app/components/shared/FileTypeIcon.tsx b/frontend/src/app/components/shared/FileTypeIcon.tsx new file mode 100644 index 0000000..0687e06 --- /dev/null +++ b/frontend/src/app/components/shared/FileTypeIcon.tsx @@ -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`} />; + } +} diff --git a/frontend/src/app/components/shared/HeaderActionsMenu.tsx b/frontend/src/app/components/shared/HeaderActionsMenu.tsx index c8e712e..afe2949 100644 --- a/frontend/src/app/components/shared/HeaderActionsMenu.tsx +++ b/frontend/src/app/components/shared/HeaderActionsMenu.tsx @@ -6,8 +6,8 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; +} from "@/app/components/ui/dropdown-menu"; +import { cn } from "@/app/lib/utils"; export type HeaderActionsMenuItem = { label: string; diff --git a/frontend/src/app/components/shared/MfaLoginGate.tsx b/frontend/src/app/components/shared/MfaLoginGate.tsx index 1cf06b5..bf0f367 100644 --- a/frontend/src/app/components/shared/MfaLoginGate.tsx +++ b/frontend/src/app/components/shared/MfaLoginGate.tsx @@ -2,9 +2,9 @@ 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 { useAuth } from "@/app/contexts/AuthContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import { needsMfaVerification } from "../popups/MfaVerificationPopup"; type GateState = "idle" | "checking" | "required" | "verified"; const MFA_VERIFIED_AT_KEY = "mike:mfa-verified-at"; diff --git a/frontend/src/app/components/shared/Modal.tsx b/frontend/src/app/components/shared/Modal.tsx deleted file mode 100644 index b5b518d..0000000 --- a/frontend/src/app/components/shared/Modal.tsx +++ /dev/null @@ -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> - ); -} diff --git a/frontend/src/app/components/shared/PageHeader.tsx b/frontend/src/app/components/shared/PageHeader.tsx index c26893e..6bc2aeb 100644 --- a/frontend/src/app/components/shared/PageHeader.tsx +++ b/frontend/src/app/components/shared/PageHeader.tsx @@ -12,7 +12,7 @@ import { import { createPortal } from "react-dom"; import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react"; import { usePageChrome } from "@/app/contexts/PageChromeContext"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; export interface PageHeaderBreadcrumb { label?: ReactNode; diff --git a/frontend/src/app/components/shared/PeopleModal.tsx b/frontend/src/app/components/shared/PeopleModal.tsx deleted file mode 100644 index fba63ef..0000000 --- a/frontend/src/app/components/shared/PeopleModal.tsx +++ /dev/null @@ -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> - ); -} diff --git a/frontend/src/app/components/shared/ProjectPicker.tsx b/frontend/src/app/components/shared/ProjectPicker.tsx deleted file mode 100644 index 05a945c..0000000 --- a/frontend/src/app/components/shared/ProjectPicker.tsx +++ /dev/null @@ -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> - </> - ); -} diff --git a/frontend/src/app/components/shared/RenameableTitle.tsx b/frontend/src/app/components/shared/RenameableTitle.tsx deleted file mode 100644 index db1560c..0000000 --- a/frontend/src/app/components/shared/RenameableTitle.tsx +++ /dev/null @@ -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> - ); -} diff --git a/frontend/src/app/components/shared/RowActions.tsx b/frontend/src/app/components/shared/RowActions.tsx index 999e359..375997b 100644 --- a/frontend/src/app/components/shared/RowActions.tsx +++ b/frontend/src/app/components/shared/RowActions.tsx @@ -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 && ( diff --git a/frontend/src/app/components/shared/SidebarChatItem.tsx b/frontend/src/app/components/shared/SidebarChatItem.tsx index 8e9a606..1531e76 100644 --- a/frontend/src/app/components/shared/SidebarChatItem.tsx +++ b/frontend/src/app/components/shared/SidebarChatItem.tsx @@ -7,12 +7,12 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +} from "@/app/components/ui/dropdown-menu"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; -import { useAuth } from "@/contexts/AuthContext"; -import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; import type { Chat } from "@/app/components/shared/types"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; interface Props { chat: Chat; @@ -146,7 +146,7 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props </DropdownMenu> </> )} - <OwnerOnlyModal + <OwnerOnlyPopup open={!!ownerOnlyAction} action={ownerOnlyAction ?? undefined} onClose={() => setOwnerOnlyAction(null)} diff --git a/frontend/src/app/components/shared/TablePrimitive.tsx b/frontend/src/app/components/shared/TablePrimitive.tsx index 73d30e6..b4fb8a6 100644 --- a/frontend/src/app/components/shared/TablePrimitive.tsx +++ b/frontend/src/app/components/shared/TablePrimitive.tsx @@ -2,12 +2,13 @@ import { useEffect, + useRef, useState, type HTMLAttributes, type MouseEvent, type ReactNode, } from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; import { CLOSE_ROW_ACTIONS_EVENT, closeRowActionMenus, @@ -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, diff --git a/frontend/src/app/components/shared/UploadNewVersionModal.tsx b/frontend/src/app/components/shared/UploadNewVersionModal.tsx deleted file mode 100644 index fddc5c9..0000000 --- a/frontend/src/app/components/shared/UploadNewVersionModal.tsx +++ /dev/null @@ -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> - ); -} diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index c3b9806..33a1e9c 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -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,19 +565,49 @@ 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; - title: string; - type: "assistant" | "tabular"; - prompt_md: string | null; + metadata: { + title: string; + description: string | null; + type: "assistant" | "tabular"; + contributors: WorkflowContributor[]; + language: string; + version: string | null; + practice: string | null; + jurisdictions: string[] | null; + }; + skill_md: string | null; columns_config: ColumnConfig[] | null; is_system: boolean; created_at: string; - practice?: string | null; shared_by_name?: string | null; allow_edit?: boolean; is_owner?: boolean; + open_source_submission?: WorkflowOpenSourceSubmission | null; } // API helpers diff --git a/frontend/src/app/components/shared/useDirectoryData.ts b/frontend/src/app/components/shared/useDirectoryData.ts index 8e96094..a572232 100644 --- a/frontend/src/app/components/shared/useDirectoryData.ts +++ b/frontend/src/app/components/shared/useDirectoryData.ts @@ -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); }, ); }) diff --git a/frontend/src/app/components/shared/DocxView.tsx b/frontend/src/app/components/shared/views/DocxView.tsx similarity index 98% rename from frontend/src/app/components/shared/DocxView.tsx rename to frontend/src/app/components/shared/views/DocxView.tsx index c378f9d..5a9a279 100644 --- a/frontend/src/app/components/shared/DocxView.tsx +++ b/frontend/src/app/components/shared/views/DocxView.tsx @@ -3,12 +3,12 @@ import { useEffect, useMemo, useRef } from "react"; import { Loader2 } from "lucide-react"; import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes"; -import { supabase } from "@/lib/supabase"; +import { supabase } from "@/app/lib/supabase"; import { clearDocxQuoteHighlights, highlightDocxQuote, } from "./highlightDocxQuote"; -import type { CitationQuote } from "./types"; +import type { CitationQuote } from "../types"; interface Props { documentId: string; @@ -73,7 +73,6 @@ interface Props { */ onScrollChange?: (scrollTop: number) => void; rounded?: boolean; - bordered?: boolean; } function findEditElement( @@ -209,7 +208,6 @@ export function DocxView({ initialScrollTop, onScrollChange, rounded = true, - bordered = true, }: Props) { const scrollRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null); @@ -467,7 +465,7 @@ export function DocxView({ return ( <div - className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`} + className={`relative flex flex-col flex-1 overflow-hidden bg-gray-100 ${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"> @@ -484,7 +482,7 @@ export function DocxView({ )} <div ref={scrollRef} - className="flex-1 overflow-auto bg-gray-100 px-5 pt-5 pb-3 docx-view-scroll" + className="flex-1 overflow-auto px-5 pt-5 pb-3 docx-view-scroll" data-document-id={documentId} data-version-id={versionId ?? ""} > diff --git a/frontend/src/app/components/shared/DocView.tsx b/frontend/src/app/components/shared/views/PdfView.tsx similarity index 98% rename from frontend/src/app/components/shared/DocView.tsx rename to frontend/src/app/components/shared/views/PdfView.tsx index 49ffa15..86e0f52 100644 --- a/frontend/src/app/components/shared/DocView.tsx +++ b/frontend/src/app/components/shared/views/PdfView.tsx @@ -4,7 +4,7 @@ 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 type { CitationQuote } from "../types"; import { clearHighlights, getPdfJs, @@ -22,7 +22,6 @@ interface Props { quote?: string; fallbackPage?: number; rounded?: boolean; - bordered?: boolean; } type QuoteEntry = { page?: number; quote: string }; @@ -40,14 +39,13 @@ type RenderedPage = { textDivs: HTMLElement[]; }; -export function DocView({ +export function PdfView({ doc, quotes, quoteFocusKey, quote, fallbackPage, rounded = true, - bordered = true, }: Props) { const containerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null); @@ -540,18 +538,17 @@ export function DocView({ 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" : ""}`} + className={`relative flex flex-col bg-gray-100 flex-1 overflow-hidden ${rounded ? "rounded-lg" : ""}`} > <div ref={scrollContainerRef} - className="flex-1 overflow-auto bg-gray-100 px-3 pt-5 pb-3" + className="flex-1 overflow-auto px-3 pt-5 pb-3" > {loading && ( <div className="flex h-full items-center justify-center"> diff --git a/frontend/src/app/components/shared/views/SpreadsheetView.tsx b/frontend/src/app/components/shared/views/SpreadsheetView.tsx new file mode 100644 index 0000000..5936789 --- /dev/null +++ b/frontend/src/app/components/shared/views/SpreadsheetView.tsx @@ -0,0 +1,518 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; +import LuckyExcel, { type LuckyExcelSheet } from "luckyexcel"; +import type { WorkbookInstance } from "@fortune-sheet/react"; +import type { Cell, Sheet } from "@fortune-sheet/core"; +import "@fortune-sheet/react/dist/index.css"; +import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc"; + +type HighlightRange = { row: [number, number]; column: [number, number] }; +type WorkbookComponent = typeof import("@fortune-sheet/react").Workbook; + +/** A cell locator to highlight, e.g. { sheet: "Q3 Budget", cell: "B7" }. */ +export type HighlightCell = { sheet?: string; cell?: string }; + +interface Props { + documentId: string; + versionId?: string | null; + /** Cell(s) to select/scroll to (from a spreadsheet citation). */ + highlightCells?: HighlightCell[]; + rounded?: boolean; +} + +/** "B" -> 1, "AA" -> 26 (0-based column index). */ +function columnLettersToIndex(letters: string): number { + let n = 0; + for (const ch of letters.toUpperCase()) { + n = n * 26 + (ch.charCodeAt(0) - 64); + } + return n - 1; +} + +/** Parse an A1 address like "B7" into 0-based { r, c }. */ +function parseA1(cell: string): { r: number; c: number } | null { + const m = cell.trim().match(/^([A-Za-z]+)(\d+)$/); + if (!m) return null; + const c = columnLettersToIndex(m[1]); + const r = Number.parseInt(m[2], 10) - 1; + if (c < 0 || r < 0) return null; + return { r, c }; +} + +/** Parse an A1 address or range ("B7" or "B7:C9") into 0-based row/col spans. */ +function parseRange(range: string): HighlightRange | null { + const [startRaw, endRaw] = range.split(":"); + const start = parseA1(startRaw); + if (!start) return null; + const end = endRaw ? parseA1(endRaw) : start; + if (!end) return null; + return { + row: [Math.min(start.r, end.r), Math.max(start.r, end.r)], + column: [Math.min(start.c, end.c), Math.max(start.c, end.c)], + }; +} + +/** + * Expand a highlight range to cover any merged ranges it intersects. Fortune- + * sheet paints a merge as one block anchored at its top-left cell and never + * paints the covered cells, so a citation to a covered cell (e.g. B1 inside + * A1:C1) would otherwise highlight nothing. The model is asked to cite the full + * merged range, but this is a deterministic fallback for when it cites a covered + * cell anyway. Expanding to the anchor makes `afterRenderCell` paint the block. + */ +function expandRangeForMerges( + sheet: Sheet, + range: HighlightRange, +): HighlightRange { + const merges = (sheet.config as { merge?: Record<string, MergeInfo> }) + ?.merge; + if (!merges) return range; + let [r0, r1] = range.row; + let [c0, c1] = range.column; + for (const m of Object.values(merges)) { + const mr1 = m.r + m.rs - 1; + const mc1 = m.c + m.cs - 1; + if (r0 <= mr1 && r1 >= m.r && c0 <= mc1 && c1 >= m.c) { + r0 = Math.min(r0, m.r); + r1 = Math.max(r1, mr1); + c0 = Math.min(c0, m.c); + c1 = Math.max(c1, mc1); + } + } + return { row: [r0, r1], column: [c0, c1] }; +} + +/** + * Pixel offset (from the grid origin) and size of a row/col span, derived from + * the sheet's column widths / row heights. Fortune-sheet defaults are 73px wide + * and 19px tall; per-index overrides live in `config.columnlen`/`config.rowlen`. + * Hidden rows/cols aren't accounted for, so this is best-effort (enough to + * decide whether the cell is on screen and where to center it). + */ +function rangePixelRect( + sheet: Sheet, + range: HighlightRange, +): { x: number; y: number; w: number; h: number } { + const cfg = (sheet.config ?? {}) as { + columnlen?: Record<string, number>; + rowlen?: Record<string, number>; + }; + const colLen = cfg.columnlen ?? {}; + const rowLen = cfg.rowlen ?? {}; + const colW = (c: number) => colLen[c] ?? sheet.defaultColWidth ?? 73; + const rowH = (r: number) => rowLen[r] ?? sheet.defaultRowHeight ?? 19; + + let x = 0; + for (let c = 0; c < range.column[0]; c++) x += colW(c); + let w = 0; + for (let c = range.column[0]; c <= range.column[1]; c++) w += colW(c); + let y = 0; + for (let r = 0; r < range.row[0]; r++) y += rowH(r); + let h = 0; + for (let r = range.row[0]; r <= range.row[1]; r++) h += rowH(r); + return { x, y, w, h }; +} + +type MergeInfo = { r: number; c: number; rs: number; cs: number }; +type CellData = { r: number; c: number; v: Record<string, unknown> }; + +/** + * Expand `config.merge` onto the cells. Luckyexcel records merges in + * `config.merge` but Fortune-sheet only renders a merge when the cells carry + * `mc` (the anchor gets `{r,c,rs,cs}`; every covered cell points back with + * `{r,c}`). Without this, merged ranges render as separate single cells. + */ +function applyMergeCells(sheets: LuckyExcelSheet[]): void { + for (const sheet of sheets) { + const merges = (sheet.config as { merge?: Record<string, MergeInfo> }) + ?.merge; + if (!merges) continue; + + if (!Array.isArray(sheet.celldata)) sheet.celldata = []; + const celldata = sheet.celldata as CellData[]; + + const byKey = new Map<string, CellData>(); + for (const entry of celldata) { + if (typeof entry?.r === "number" && typeof entry?.c === "number") { + byKey.set(`${entry.r}_${entry.c}`, entry); + } + } + const ensureCell = (r: number, c: number): CellData => { + const key = `${r}_${c}`; + let entry = byKey.get(key); + if (!entry) { + entry = { r, c, v: {} }; + celldata.push(entry); + byKey.set(key, entry); + } + if (!entry.v || typeof entry.v !== "object") entry.v = {}; + return entry; + }; + + for (const mc of Object.values(merges)) { + ensureCell(mc.r, mc.c).v.mc = { + r: mc.r, + c: mc.c, + rs: mc.rs, + cs: mc.cs, + }; + for (let rr = mc.r; rr < mc.r + mc.rs; rr++) { + for (let cc = mc.c; cc < mc.c + mc.cs; cc++) { + if (rr === mc.r && cc === mc.c) continue; + ensureCell(rr, cc).v.mc = { r: mc.r, c: mc.c }; + } + } + } + } +} + +/** + * Make text cells overflow into empty adjacent cells, mirroring Excel's default. + * Fortune-sheet only spills a cell's text when its `tb` (text-break) is "1"; + * Luckyexcel leaves text cells clipping, so we set `tb: "1"` on unwrapped, + * non-merged text cells. Fortune-sheet still only paints the overflow over + * genuinely empty neighbors, so this matches Excel (numbers/dates keep the + * default and are not spilled). + */ +function applyExcelTextOverflow(sheets: LuckyExcelSheet[]): void { + for (const sheet of sheets) { + const celldata = sheet.celldata; + if (!Array.isArray(celldata)) continue; + for (const entry of celldata) { + const cell = (entry as { v?: Record<string, unknown> } | null)?.v; + if (!cell || typeof cell !== "object") continue; + if (cell.mc) continue; // part of a merge - leave as-is + if (cell.tb === "2") continue; // explicit wrap-text - keep + if (typeof cell.v === "string" && cell.v.length > 0) { + cell.tb = "1"; // text: overflow into empty neighbors + } + } + } +} + +/** + * Tint a row/column header cell gray via Fortune-sheet's own header render + * hooks. Fortune-sheet paints the header labels (A/B/C, 1/2/3) onto the grid + * canvas with a white fill and dark text; we lay a translucent gray over that + * cell in the `afterRender…HeaderCell` pass. A low alpha darkens the white + * background into gray while leaving the dark labels legible — unlike an opaque + * CSS background on the header overlay divs, which sits in front of the canvas + * and hides the labels entirely. + */ +const HEADER_TINT = "rgba(148, 163, 184, 0.18)"; + +function tintHeaderCell( + x: number, + y: number, + width: number, + height: number, + ctx: CanvasRenderingContext2D, +): void { + ctx.save(); + ctx.fillStyle = HEADER_TINT; + ctx.fillRect(x, y, width, height); + ctx.restore(); +} + +/** + * Renders an Excel workbook as a read-only grid using Fortune-sheet. It fetches + * the document's raw `.xlsx`/`.xlsm`/`.xls` bytes itself (via /display) and + * converts them to Fortune-sheet data with Luckyexcel, preserving the original + * styling (fills, fonts, borders, merges). + * + * Spreadsheet citations are highlighted by cell: `highlightCells` activates the + * cited sheet tab and scrolls the A1 address/range into view, where a canvas + * hook paints the highlight. + */ +export function SpreadsheetView({ + documentId, + versionId, + highlightCells, + rounded = true, +}: Props) { + const workbookRef = useRef<WorkbookInstance>(null); + // The frame element, used to reach Fortune-sheet's scrollbars for measuring + // the current scroll offset and viewport size when deciding whether to scroll. + const containerRef = useRef<HTMLDivElement>(null); + // Current highlight, read by the render hook. A ref (not state) so updating + // it never re-mounts the Workbook or changes the settings object. + const highlightRef = useRef<HighlightRange | null>(null); + const [sheets, setSheets] = useState<Sheet[] | null>(null); + const [WorkbookComponent, setWorkbookComponent] = + useState<WorkbookComponent | null>(null); + const [error, setError] = useState<string | null>(null); + + // Fetch the raw workbook bytes. For spreadsheets, /display returns the + // original .xlsx/.xlsm/.xls bytes rather than a PDF rendition. + const { result, error: fetchError } = useFetchSingleDoc( + documentId, + versionId, + ); + + // Fortune-sheet touches browser-only APIs while loading, so keep the import + // inside the client component even though this file also owns the view. + useEffect(() => { + let cancelled = false; + import("@fortune-sheet/react").then((mod) => { + if (!cancelled) setWorkbookComponent(() => mod.Workbook); + }); + return () => { + cancelled = true; + }; + }, []); + + // Stable key so the highlight effect only re-runs when the target changes. + const highlightKey = useMemo( + () => + (highlightCells ?? []) + .map((h) => `${h.sheet ?? ""}!${h.cell ?? ""}`) + .join("|"), + [highlightCells], + ); + + // Parse the workbook with Luckyexcel, which converts the .xlsx to + // Fortune-sheet data while preserving styling (fills, fonts, borders, + // alignment, column widths). + useEffect(() => { + if (!result) return; + if (result.type !== "spreadsheet") { + setError("This spreadsheet could not be displayed."); + return; + } + let cancelled = false; + setSheets(null); + setError(null); + + try { + const file = new File([result.buffer], "spreadsheet.xlsx"); + LuckyExcel.transformExcelToLucky(file, (exportJson) => { + if (cancelled) return; + if (exportJson?.sheets?.length) { + applyMergeCells(exportJson.sheets); + applyExcelTextOverflow(exportJson.sheets); + setSheets(exportJson.sheets as unknown as Sheet[]); + } else { + setError("This spreadsheet could not be displayed."); + } + }); + } catch { + if (!cancelled) + setError("This spreadsheet could not be displayed."); + } + + return () => { + cancelled = true; + }; + }, [result]); + + // Draw the citation highlight on the canvas. Stable identity so the Workbook + // settings never change; it reads the live target from `highlightRef`. We use + // this instead of `setSelection`, whose in-place mutation of the range object + // crashes under React Strict Mode's double-invoked immer producer. + const afterRenderCell = useCallback( + ( + _cell: Cell | null, + info: { + row: number; + column: number; + startX: number; + startY: number; + endX: number; + endY: number; + }, + ctx: CanvasRenderingContext2D, + ) => { + const range = highlightRef.current; + if (!range) return; + if ( + info.row < range.row[0] || + info.row > range.row[1] || + info.column < range.column[0] || + info.column > range.column[1] + ) { + return; + } + const w = info.endX - info.startX; + const h = info.endY - info.startY; + ctx.save(); + ctx.fillStyle = "rgba(59, 130, 246, 0.16)"; + ctx.fillRect(info.startX, info.startY, w, h); + ctx.strokeStyle = "#3b82f6"; + ctx.lineWidth = 2; + ctx.strokeRect(info.startX + 1, info.startY + 1, w - 2, h - 2); + ctx.restore(); + }, + [], + ); + const hooks = useMemo( + () => ({ + afterRenderCell, + // Tint the header strips gray while keeping the A/B/C, 1/2/3 labels + // visible (see tintHeaderCell). Column cells fill from y=0; row cells + // fill from x=0 — matching Fortune-sheet's own default header rects. + afterRenderColumnHeaderCell: ( + _char: string, + _idx: number, + left: number, + width: number, + height: number, + ctx: CanvasRenderingContext2D, + ) => tintHeaderCell(left, 0, width, height, ctx), + afterRenderRowHeaderCell: ( + _num: string, + _idx: number, + top: number, + width: number, + height: number, + ctx: CanvasRenderingContext2D, + ) => tintHeaderCell(0, top, width, height, ctx), + }), + [afterRenderCell], + ); + + // Activate the cited sheet, bring the cell into view, and repaint the + // highlight. We only scroll when the cell is off screen (centering it); + // when it's already visible we leave the viewport put and force a redraw so + // the `afterRenderCell` hook repaints the new highlight (and clears the old + // one). Both paths must trigger a redraw: `scroll()` repaints because the + // position changes, and the synthetic "resize" repaints in place via + // Fortune-sheet's window resize handler. + useEffect(() => { + if (!sheets) return; + const target = highlightCells?.[0]; + + const sheetIndex = target?.sheet + ? Math.max( + 0, + sheets.findIndex((s) => s.name === target.sheet), + ) + : 0; + + const parsed = target?.cell ? parseRange(target.cell) : null; + const range = parsed + ? expandRangeForMerges(sheets[sheetIndex], parsed) + : null; + highlightRef.current = range; + if (!range && !target?.sheet) return; + + const timer = window.setTimeout(() => { + const inst = workbookRef.current; + if (!inst) return; + try { + inst.activateSheet({ index: sheetIndex }); + if (!range) return; + + const container = containerRef.current; + const sbX = container?.querySelector<HTMLElement>( + ".luckysheet-scrollbar-x", + ); + const sbY = container?.querySelector<HTMLElement>( + ".luckysheet-scrollbar-y", + ); + + // Without the scrollbars we can't measure the viewport; fall back to + // corner-scrolling, which at least brings the cell in and repaints. + if (!sbX || !sbY) { + inst.scroll({ + targetRow: range.row[0], + targetColumn: range.column[0], + }); + return; + } + + const rect = rangePixelRect(sheets[sheetIndex], range); + const curLeft = sbX.scrollLeft; + const curTop = sbY.scrollTop; + const viewW = sbX.clientWidth; + const viewH = sbY.clientHeight; + const visible = + rect.x >= curLeft && + rect.x + rect.w <= curLeft + viewW && + rect.y >= curTop && + rect.y + rect.h <= curTop + viewH; + + if (visible) { + // On screen: keep the viewport still, just repaint the highlight. + window.dispatchEvent(new Event("resize")); + } else { + // Off screen: center the cell. scroll() re-renders and repaints. + inst.scroll({ + scrollLeft: Math.max( + 0, + Math.round(rect.x - (viewW - rect.w) / 2), + ), + scrollTop: Math.max( + 0, + Math.round(rect.y - (viewH - rect.h) / 2), + ), + }); + } + } catch { + /* highlighting is best-effort */ + } + }, 200); + return () => window.clearTimeout(timer); + }, [sheets, highlightCells, highlightKey]); + + const frameClass = `fortune-sheet-viewer relative flex flex-col flex-1 min-h-0 overflow-hidden ${rounded ? "rounded-lg" : ""}`; + + const message = + error ?? (fetchError ? "Failed to load spreadsheet." : null); + if (message) { + return ( + <div className={frameClass}> + <div className="flex h-full items-center justify-center px-6 text-center text-sm text-gray-500"> + {message} + </div> + </div> + ); + } + + if (!sheets || !WorkbookComponent) { + return ( + <div className={frameClass}> + <div className="flex h-full items-center justify-center"> + <Loader2 className="h-7 w-7 animate-spin text-gray-400" /> + </div> + </div> + ); + } + + return ( + <div ref={containerRef} className={frameClass}> + <div className="relative min-h-0 flex-1"> + <WorkbookComponent + ref={workbookRef} + data={sheets} + hooks={hooks} + allowEdit={false} + showToolbar={false} + showFormulaBar={false} + /> + <style jsx global>{` + /* The row/col header strips are transparent overlays over + the label canvas — leave them so the labels show. Only the + corner (no label) gets a matching gray background here; the + header strips are tinted on the canvas via render hooks. */ + .fortune-sheet-viewer .fortune-left-top { + background-color: #eceef2; + } + + .fortune-sheet-viewer .fortune-row-header-hover, + .fortune-sheet-viewer .fortune-col-header-hover { + background-color: rgba(209, 213, 219, 0.65); + } + + .fortune-sheet-viewer .fortune-row-header-selected, + .fortune-sheet-viewer .fortune-col-header-selected { + background-color: rgba(156, 163, 175, 0.28); + } + `}</style> + </div> + </div> + ); +} + +export default SpreadsheetView; diff --git a/frontend/src/app/components/shared/highlightDocxQuote.ts b/frontend/src/app/components/shared/views/highlightDocxQuote.ts similarity index 100% rename from frontend/src/app/components/shared/highlightDocxQuote.ts rename to frontend/src/app/components/shared/views/highlightDocxQuote.ts diff --git a/frontend/src/app/components/shared/highlightQuote.ts b/frontend/src/app/components/shared/views/highlightQuote.ts similarity index 100% rename from frontend/src/app/components/shared/highlightQuote.ts rename to frontend/src/app/components/shared/views/highlightQuote.ts diff --git a/frontend/src/components/site-logo.tsx b/frontend/src/app/components/site-logo.tsx similarity index 96% rename from frontend/src/components/site-logo.tsx rename to frontend/src/app/components/site-logo.tsx index ce4ecdd..6438300 100644 --- a/frontend/src/components/site-logo.tsx +++ b/frontend/src/app/components/site-logo.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { MikeIcon } from "@/components/chat/mike-icon"; +import { MikeIcon } from "@/app/components/chat/mike-icon"; interface SiteLogoProps { size?: "sm" | "md" | "lg" | "xl"; diff --git a/frontend/src/app/components/tabular/AddColumnModal.tsx b/frontend/src/app/components/tabular/AddColumnModal.tsx index 48be4a6..62fb7a2 100644 --- a/frontend/src/app/components/tabular/AddColumnModal.tsx +++ b/frontend/src/app/components/tabular/AddColumnModal.tsx @@ -1,20 +1,17 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { ChevronDown, Plus, X } from "lucide-react"; import type { ColumnConfig, ColumnFormat } from "../shared/types"; import { generateTabularColumnPrompt } from "@/app/lib/mikeApi"; -import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat"; +import { FORMAT_OPTIONS } from "./columnFormat"; import { TAG_COLORS } from "./pillUtils"; import { getPresetConfig, PROMPT_PRESETS } from "./columnPresets"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { Modal } from "../modals/Modal"; +import { ModalFieldLabel } from "../modals/ModalFieldLabel"; +import { ModalSelect } from "../modals/ModalSelect"; +import { ModalTextarea } from "../modals/ModalTextarea"; +import { ModalTextInput } from "../modals/ModalTextInput"; interface ColumnDraft { name: string; @@ -44,7 +41,9 @@ interface Props { export function AddColumnModal({ open, existingCount, onClose, onAdd, editingColumn, onSave, onDelete }: Props) { const isEditing = !!editingColumn; + const formId = "add-column-modal-form"; const [columns, setColumns] = useState<ColumnDraft[]>([{ ...EMPTY_DRAFT }]); + const [collapsedIndices, setCollapsedIndices] = useState<number[]>([]); const [generatingIndices, setGeneratingIndices] = useState<number[]>([]); const [presetsOpenIndex, setPresetsOpenIndex] = useState<number | null>( null, @@ -64,6 +63,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol } else { setColumns([{ ...EMPTY_DRAFT }]); } + setCollapsedIndices([]); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { @@ -85,6 +85,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol function resetForm() { setColumns([{ ...EMPTY_DRAFT }]); + setCollapsedIndices([]); setGeneratingIndices([]); } @@ -109,6 +110,24 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol ? [{ ...EMPTY_DRAFT }] : prev.filter((_, i) => i !== index), ); + setCollapsedIndices((prev) => + prev + .filter((collapsedIndex) => collapsedIndex !== index) + .map((collapsedIndex) => + collapsedIndex > index + ? collapsedIndex - 1 + : collapsedIndex, + ), + ); + } + + function toggleColumnCollapsed(index: number) { + setCollapsedIndices((prev) => + prev.includes(index) + ? prev.filter((collapsedIndex) => collapsedIndex !== index) + : [...prev, index], + ); + setPresetsOpenIndex(null); } function commitTag(index: number) { @@ -190,35 +209,90 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol onClose(); } - return createPortal( - <div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-6 pt-5 pb-2"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - <span>Tabular Review</span> - <span>›</span> - <span>{isEditing ? "Edit column" : "New column"}</span> - </div> - <button - onClick={handleClose} - className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors" - > - <X className="h-4 w-4" /> - </button> - </div> - - <form - onSubmit={handleSubmit} - className="flex flex-col min-h-0 flex-1" - > - {/* Body */} - <div className="px-6 pt-3 pb-5 space-y-5 overflow-y-auto flex-1"> + return ( + <Modal + open={open} + onClose={handleClose} + breadcrumbs={[ + "Tabular Review", + isEditing ? "Edit column" : "New column", + ]} + primaryAction={{ + label: isEditing ? "Save changes" : "Add columns", + type: "submit", + form: formId, + disabled: columns.some( + (col) => !col.name.trim() || !col.prompt.trim(), + ), + }} + cancelAction={{ label: "Cancel", onClick: handleClose }} + secondaryAction={ + isEditing && onDelete + ? { + label: "Delete", + variant: "danger", + onClick: onDelete, + } + : undefined + } + > + <form + id={formId} + onSubmit={handleSubmit} + className="flex min-h-0 flex-1 flex-col" + > + <div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-3"> {columns.map((column, index) => ( <div key={index} - className="rounded-xl border border-gray-200 p-4" + className="relative" > + {(() => { + const nameInputId = `column-${index}-name`; + const formatInputId = `column-${index}-format`; + const tagInputId = `column-${index}-tag`; + const promptInputId = `column-${index}-prompt`; + const isCollapsed = + collapsedIndices.includes(index); + + return ( + <> + <div className="mb-4 flex items-center justify-between gap-3"> + <button + type="button" + onClick={() => + toggleColumnCollapsed( + index, + ) + } + aria-expanded={!isCollapsed} + className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-gray-300" + > + <ChevronDown + className={`h-4 w-4 shrink-0 text-gray-600 transition-transform ${isCollapsed ? "-rotate-90" : ""}`} + /> + <h3 className="font-serif text-2xl text-gray-950"> + Column {index + 1} + </h3> + </button> + {columns.length > 1 && ( + <button + type="button" + onClick={() => + removeColumn(index) + } + className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500" + aria-label="Remove column" + > + <X className="h-4 w-4" /> + </button> + )} + </div> + {!isCollapsed && ( + <> + <ModalFieldLabel htmlFor={nameInputId}> + Column title + </ModalFieldLabel> {/* Name row */} <div className="flex items-start gap-2"> {/* Input + preset dropdown anchored to this wrapper */} @@ -230,8 +304,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol : null } > - <input + <ModalTextInput + id={nameInputId} type="text" + variant="minimal" value={column.name} onChange={(e) => { const name = e.target.value; @@ -252,7 +328,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol }); }} placeholder="Column name" - className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent" + className="flex-1" autoFocus={index === 0} /> <button @@ -315,74 +391,39 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol </div> )} </div> - {columns.length > 1 && ( - <button - type="button" - onClick={() => removeColumn(index)} - className="mt-1.5 rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500" - > - <X className="h-4 w-4" /> - </button> - )} </div> {/* Format */} <div className="mt-4"> - <label className="text-sm font-medium text-gray-500"> + <ModalFieldLabel htmlFor={formatInputId}> Format - </label> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none"> - <span className="flex items-center gap-2"> - {(() => { - const Icon = formatIcon( - column.format, - ); - return ( - <Icon className="h-3.5 w-3.5 text-gray-400" /> - ); - })()} - {formatLabel(column.format)} - </span> - <ChevronDown className="h-3.5 w-3.5 text-gray-400" /> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent - align="start" - className="z-[200]" - > - <DropdownMenuRadioGroup - value={column.format} - onValueChange={(v) => - updateColumn(index, { - format: v as ColumnFormat, - tags: [], - tagInput: "", - }) - } - > - {FORMAT_OPTIONS.map((o) => ( - <DropdownMenuRadioItem - key={o.value} - value={o.value} - > - <o.icon className="h-3.5 w-3.5 text-gray-400" /> - {o.label} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> + </ModalFieldLabel> + <ModalSelect + id={formatInputId} + value={column.format} + options={FORMAT_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + icon: option.icon, + iconClassName: option.iconClassName, + }))} + onChange={(value) => + updateColumn(index, { + format: value as ColumnFormat, + tags: [], + tagInput: "", + }) + } + /> </div> {/* Tag input */} {column.format === "tag" && ( <div className="mt-3"> - <label className="text-sm font-medium text-gray-500"> + <ModalFieldLabel htmlFor={tagInputId}> Tags - </label> - <div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400"> + </ModalFieldLabel> + <div className="mt-1 flex flex-wrap gap-1.5 rounded-xl border border-white/70 bg-white/55 px-2 py-1.5 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl"> {column.tags.map((tag, tagIdx) => ( <span key={tag} @@ -409,8 +450,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol </button> </span> ))} - <input + <ModalTextInput + id={tagInputId} type="text" + variant="minimal" value={column.tagInput} onChange={(e) => updateColumn(index, { @@ -423,7 +466,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol } onBlur={() => commitTag(index)} placeholder="Add tag…" - className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none" + className="min-w-[80px] flex-1 bg-transparent font-sans text-sm text-gray-700 shadow-none placeholder:text-gray-400" /> </div> <p className="mt-1 text-xs text-gray-400"> @@ -434,9 +477,12 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol {/* Prompt */} <div className="mt-4 flex items-center justify-between"> - <label className="text-sm font-medium text-gray-500"> + <ModalFieldLabel + htmlFor={promptInputId} + className="mb-0" + > Prompt - </label> + </ModalFieldLabel> <button type="button" onClick={() => @@ -456,7 +502,8 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol Auto-Generate Prompt </button> </div> - <textarea + <ModalTextarea + id={promptInputId} rows={6} value={column.prompt} onChange={(e) => @@ -465,8 +512,13 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol }) } placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…" - className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed" + className="mt-2 min-h-36" /> + </> + )} + </> + ); + })()} </div> ))} @@ -480,43 +532,8 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol Add another column </button> )} - </div> - - {/* Footer */} - <div className="flex items-center justify-between border-t border-gray-100 px-6 py-4"> - <div> - {isEditing && onDelete && ( - <button - type="button" - onClick={onDelete} - className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors" - > - Delete - </button> - )} - </div> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={handleClose} - className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors" - > - Cancel - </button> - <button - type="submit" - disabled={columns.some( - (col) => !col.name.trim() || !col.prompt.trim(), - )} - className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors" - > - {isEditing ? "Save changes" : "Add columns"} - </button> - </div> - </div> - </form> - </div> - </div>, - document.body, + </div> + </form> + </Modal> ); } diff --git a/frontend/src/app/components/tabular/AddNewTRModal.tsx b/frontend/src/app/components/tabular/AddNewTRModal.tsx deleted file mode 100644 index ff142c3..0000000 --- a/frontend/src/app/components/tabular/AddNewTRModal.tsx +++ /dev/null @@ -1,488 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { Check, ChevronDown, Loader2, Upload } from "lucide-react"; -import type { Document, Project, Workflow } from "../shared/types"; -import { - getProject, - listProjects, - listStandaloneDocuments, - listWorkflows, - uploadProjectDocument, - uploadStandaloneDocument, -} from "@/app/lib/mikeApi"; -import { FileDirectory } from "../shared/FileDirectory"; -import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows"; -import { Modal } from "../shared/Modal"; - -interface Props { - open: boolean; - onClose: () => void; - onAdd: ( - title: string, - projectId?: string, - documentIds?: string[], - columnsConfig?: Workflow["columns_config"], - ) => void; - projects?: Project[]; - /** When provided, skip the project/directory picker and show only these docs */ - projectDocs?: Document[]; - projectName?: string; - projectCmNumber?: string | null; -} - -export function AddNewTRModal({ - open, - onClose, - onAdd, - projects = [], - projectDocs: fixedProjectDocs, - projectName, - projectCmNumber, -}: Props) { - const isProjectMode = fixedProjectDocs !== undefined; - const [title, setTitle] = useState(""); - const [underProject, setUnderProject] = useState(false); - const [selectedProjectId, setSelectedProjectId] = useState(""); - const [projectDropdownOpen, setProjectDropdownOpen] = useState(false); - - // Project-scoped docs (when underProject is true and no fixedProjectDocs) - const [projectDocs, setProjectDocs] = useState<Document[]>([]); - const [loadingDocs, setLoadingDocs] = useState(false); - - // Full directory (when underProject is false) - const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]); - const [directoryProjects, setDirectoryProjects] = useState<Project[]>( - [], - ); - const [loadingDirectory, setLoadingDirectory] = useState(false); - - const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>( - new Set(), - ); - const [uploading, setUploading] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - - // Workflow templates - const [workflows, setWorkflows] = useState<Workflow[]>([]); - const [loadingWorkflows, setLoadingWorkflows] = useState(false); - const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>( - null, - ); - const [workflowDropdownOpen, setWorkflowDropdownOpen] = useState(false); - const formId = "new-tabular-review-modal-form"; - - useEffect(() => { - if (!open) return; - - setLoadingWorkflows(true); - const builtinTabular = BUILT_IN_WORKFLOWS.filter( - (w) => w.type === "tabular", - ); - listWorkflows("tabular") - .then((custom) => setWorkflows([...builtinTabular, ...custom])) - .catch(() => setWorkflows(builtinTabular)) - .finally(() => setLoadingWorkflows(false)); - - if (isProjectMode) { - setSelectedDocIds( - new Set((fixedProjectDocs ?? []).map((d) => d.id)), - ); - return; - } - - setLoadingDirectory(true); - // /projects only returns counts, not the documents array — fetch - // each project in parallel so FileDirectory can render the docs - // when the user expands a folder. - Promise.all([listStandaloneDocuments(), listProjects()]) - .then(async ([docs, projs]) => { - setStandaloneDocs( - [...docs].sort((a, b) => - (b.created_at ?? "").localeCompare(a.created_at ?? ""), - ), - ); - const fullProjects = await Promise.all( - projs.map((p) => getProject(p.id)), - ); - setDirectoryProjects(fullProjects); - }) - .catch(() => { - setStandaloneDocs([]); - setDirectoryProjects([]); - }) - .finally(() => setLoadingDirectory(false)); - }, [open]); // eslint-disable-line react-hooks/exhaustive-deps - - if (!open) return null; - - function handleClose() { - setTitle(""); - setUnderProject(false); - setSelectedProjectId(""); - setProjectDropdownOpen(false); - setProjectDocs([]); - setStandaloneDocs([]); - setDirectoryProjects([]); - setSelectedDocIds(new Set()); - setSelectedWorkflowId(null); - setWorkflowDropdownOpen(false); - onClose(); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!title.trim()) return; - if (underProject && !selectedProjectId) return; - const selectedWorkflow = workflows.find( - (w) => w.id === selectedWorkflowId, - ); - onAdd( - title.trim(), - underProject ? selectedProjectId : undefined, - selectedDocIds.size > 0 ? [...selectedDocIds] : undefined, - selectedWorkflow?.columns_config ?? undefined, - ); - handleClose(); - } - - async function handleSelectProject(projectId: string) { - setSelectedProjectId(projectId); - setProjectDropdownOpen(false); - setProjectDocs([]); - setSelectedDocIds(new Set()); - setLoadingDocs(true); - try { - const proj = await getProject(projectId); - const docs = (proj.documents ?? []).filter( - (d) => d.status === "ready", - ); - setProjectDocs(docs); - setSelectedDocIds(new Set(docs.map((d) => d.id))); - } finally { - setLoadingDocs(false); - } - } - - 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) => - underProject && selectedProjectId - ? uploadProjectDocument(selectedProjectId, f) - : uploadStandaloneDocument(f), - ), - ); - if (underProject && selectedProjectId) { - setProjectDocs((prev) => [...uploaded, ...prev]); - } else { - setStandaloneDocs((prev) => [...uploaded, ...prev]); - } - uploaded.forEach((d) => - setSelectedDocIds((prev) => new Set([...prev, d.id])), - ); - } catch (err) { - console.error("Upload failed:", err); - } finally { - setUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ""; - } - } - - const selectedProject = projects.find((p) => p.id === selectedProjectId); - const selectedWorkflow = workflows.find((w) => w.id === selectedWorkflowId); - - // What to show in the directory depends on mode and toggle state - const directoryStandalone = isProjectMode - ? (fixedProjectDocs ?? []) - : underProject - ? [] - : standaloneDocs; - const directoryFolders = isProjectMode - ? [] - : underProject - ? [] - : directoryProjects; - const flatProjectDocs: Document[] = - !isProjectMode && underProject ? projectDocs : []; - const directoryLoading = isProjectMode - ? false - : underProject - ? loadingDocs - : loadingDirectory; - const showDirectory = isProjectMode || !underProject || !!selectedProjectId; - const breadcrumbs = - isProjectMode && projectName - ? [ - "Projects", - `${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`, - "New Tabular Review", - ] - : ["Tabular Reviews", "New Tabular Review"]; - - return ( - <Modal - open={open} - onClose={handleClose} - breadcrumbs={breadcrumbs} - 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, - }} - primaryAction={{ - label: "Create", - type: "submit", - form: formId, - disabled: !title.trim() || (underProject && !selectedProjectId), - }} - > - <input - ref={fileInputRef} - type="file" - accept=".pdf,.docx,.doc" - multiple - className="hidden" - onChange={handleUpload} - /> - <form - id={formId} - onSubmit={handleSubmit} - className="flex flex-col min-h-0 flex-1" - > - <div className="space-y-5"> - <input - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - placeholder="Review name" - className="w-full text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent" - autoFocus - /> - - {/* Workflow template */} - <div className="space-y-2"> - <p className="text-xs font-medium text-gray-700"> - Workflow Template - </p> - <div className="relative"> - <button - type="button" - onClick={() => - setWorkflowDropdownOpen((o) => !o) - } - disabled={loadingWorkflows} - className="flex items-center justify-between w-full rounded-lg border border-gray-200 px-3 py-2 text-sm hover:border-gray-400 focus:outline-none bg-white transition-colors" - > - <div className="flex items-center gap-2 min-w-0"> - {loadingWorkflows && ( - <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" /> - )} - <span - className={ - selectedWorkflow - ? "text-gray-800 truncate" - : "text-gray-400" - } - > - {loadingWorkflows - ? "Loading templates…" - : selectedWorkflow - ? selectedWorkflow.title - : "No template — start from scratch"} - </span> - </div> - <ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0 ml-2" /> - </button> - {workflowDropdownOpen && !loadingWorkflows && ( - <div className="absolute left-0 top-full z-20 mt-1 w-full rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-52"> - <button - type="button" - onClick={() => { - setSelectedWorkflowId(null); - setWorkflowDropdownOpen(false); - }} - className={`w-full text-left flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${!selectedWorkflowId ? "bg-gray-50 text-gray-900" : "text-gray-500"}`} - > - <span className="flex-1"> - No template — start from scratch - </span> - {!selectedWorkflowId && ( - <Check className="h-3.5 w-3.5 text-gray-500 shrink-0" /> - )} - </button> - {workflows.length > 0 && ( - <div className="border-t border-gray-100" /> - )} - {workflows.map((wf) => ( - <button - key={wf.id} - type="button" - onClick={() => { - setSelectedWorkflowId( - wf.id, - ); - setWorkflowDropdownOpen( - false, - ); - }} - className={`w-full text-left flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${selectedWorkflowId === wf.id ? "bg-gray-50 text-gray-900" : "text-gray-700"}`} - > - <span className="flex-1 truncate"> - {wf.title} - </span> - {selectedWorkflowId === - wf.id && ( - <Check className="h-3.5 w-3.5 text-gray-500 shrink-0" /> - )} - </button> - ))} - </div> - )} - </div> - </div> - - {/* Create under a project toggle */} - {!isProjectMode && <div className="space-y-3"> - <button - type="button" - onClick={() => { - const next = !underProject; - setUnderProject(next); - if (!next) { - setSelectedProjectId(""); - setProjectDropdownOpen(false); - setProjectDocs([]); - setSelectedDocIds(new Set()); - } - }} - className="flex items-center gap-2.5 w-fit" - > - <span - className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${underProject ? "bg-gray-900" : "bg-gray-200"}`} - > - <span - className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${underProject ? "translate-x-4" : "translate-x-0"}`} - /> - </span> - <span className="text-sm text-gray-600"> - Create under a project - </span> - </button> - - {underProject && ( - <div className="relative"> - <button - type="button" - onClick={() => - setProjectDropdownOpen((o) => !o) - } - className="flex items-center justify-between w-full rounded-lg border border-gray-200 px-3 py-2 text-sm hover:border-gray-400 focus:outline-none bg-white transition-colors" - > - <span - className={ - selectedProject - ? "text-gray-800" - : "text-gray-400" - } - > - {selectedProject - ? selectedProject.name + - (selectedProject.cm_number - ? ` (#${selectedProject.cm_number})` - : "") - : "Select project…"} - </span> - <ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" /> - </button> - {projectDropdownOpen && ( - <div className="absolute left-0 top-full z-20 mt-1 w-full rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-48"> - {projects.length === 0 ? ( - <p className="px-3 py-2 text-xs text-gray-400"> - No projects found - </p> - ) : ( - projects.map((p) => ( - <button - key={p.id} - type="button" - onClick={() => - handleSelectProject( - p.id, - ) - } - className={`w-full text-left flex items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${selectedProjectId === p.id ? "bg-gray-50 text-gray-900" : "text-gray-700"}`} - > - <span className="truncate"> - {p.name} - {p.cm_number && ( - <span className="ml-1 text-gray-400"> - (# - { - p.cm_number - } - ) - </span> - )} - </span> - {selectedProjectId === - p.id && ( - <Check className="h-3.5 w-3.5 text-gray-500 shrink-0" /> - )} - </button> - )) - )} - </div> - )} - </div> - )} - </div>} - - {/* File directory */} - {showDirectory && ( - <div className="space-y-2"> - <p className="text-xs font-medium text-gray-700"> - Select Documents - </p> - <div> - <FileDirectory - standaloneDocs={ - isProjectMode - ? directoryStandalone - : underProject - ? flatProjectDocs - : directoryStandalone - } - directoryProjects={ - isProjectMode - ? [] - : underProject - ? [] - : directoryFolders - } - loading={directoryLoading} - selectedIds={selectedDocIds} - onChange={setSelectedDocIds} - heading={isProjectMode ? "Project Documents" : "Documents"} - emptyMessage={ - isProjectMode || underProject - ? "No ready documents in this project" - : "No documents yet" - } - /> - </div> - </div> - )} - </div> - </form> - </Modal> - ); -} diff --git a/frontend/src/app/components/tabular/NewTRModal.tsx b/frontend/src/app/components/tabular/NewTRModal.tsx new file mode 100644 index 0000000..0c690d2 --- /dev/null +++ b/frontend/src/app/components/tabular/NewTRModal.tsx @@ -0,0 +1,465 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Loader2, Upload } from "lucide-react"; +import type { Document, Project, Workflow } from "../shared/types"; +import { + getProject, + listProjects, + listStandaloneDocuments, + listWorkflows, + uploadProjectDocument, + uploadStandaloneDocument, +} from "@/app/lib/mikeApi"; +import { FileDirectory } from "../shared/FileDirectory"; +import { Modal } from "../modals/Modal"; +import { ModalFieldLabel } from "../modals/ModalFieldLabel"; +import { ModalSelect } from "../modals/ModalSelect"; +import { ModalTextInput } from "../modals/ModalTextInput"; + +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters<typeof console.log>) => { + if (isDev) console.log(...args); +}; + +interface Props { + open: boolean; + onClose: () => void; + onAdd: ( + title: string, + projectId?: string, + documentIds?: string[], + columnsConfig?: Workflow["columns_config"], + ) => void; + projects?: Project[]; + /** When provided, skip the project/directory picker and show only these docs */ + projectDocs?: Document[]; + projectName?: string; + projectCmNumber?: string | null; +} + +export function NewTRModal({ + open, + onClose, + onAdd, + projects = [], + projectDocs: fixedProjectDocs, + projectName, + projectCmNumber, +}: Props) { + const isProjectMode = fixedProjectDocs !== undefined; + const [step, setStep] = useState<"details" | "documents">("details"); + const [title, setTitle] = useState(""); + const [underProject, setUnderProject] = useState(false); + const [selectedProjectId, setSelectedProjectId] = useState(""); + + // Project-scoped docs (when underProject is true and no fixedProjectDocs) + const [projectDocs, setProjectDocs] = useState<Document[]>([]); + const [loadingDocs, setLoadingDocs] = useState(false); + + // Full directory (when underProject is false) + const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]); + const [directoryProjects, setDirectoryProjects] = useState<Project[]>( + [], + ); + const [loadingDirectory, setLoadingDirectory] = useState(false); + + const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>( + new Set(), + ); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef<HTMLInputElement>(null); + + // Workflow templates + const [workflows, setWorkflows] = useState<Workflow[]>([]); + const [loadingWorkflows, setLoadingWorkflows] = useState(false); + const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>( + null, + ); + const formId = "new-tabular-review-modal-form"; + + useEffect(() => { + if (!open) return; + + setLoadingWorkflows(true); + listWorkflows("tabular") + .then((workflows) => { + devLog("[workflows/ui:tabular-review-modal] loaded", { + workflowCount: workflows.length, + systemCount: workflows.filter((workflow) => workflow.is_system) + .length, + sample: workflows.slice(0, 5).map((workflow) => ({ + id: workflow.id, + title: workflow.metadata.title, + type: workflow.metadata.type, + user_id: workflow.user_id, + is_system: workflow.is_system, + is_owner: workflow.is_owner, + })), + }); + setWorkflows(workflows); + }) + .catch((error) => { + devLog( + "[workflows/ui:tabular-review-modal] failed", + error, + ); + setWorkflows([]); + }) + .finally(() => setLoadingWorkflows(false)); + + if (isProjectMode) { + setSelectedDocIds( + new Set((fixedProjectDocs ?? []).map((d) => d.id)), + ); + return; + } + + setLoadingDirectory(true); + // /projects only returns counts, not the documents array — fetch + // each project in parallel so FileDirectory can render the docs + // when the user expands a folder. + Promise.all([listStandaloneDocuments(), listProjects()]) + .then(async ([docs, projs]) => { + setStandaloneDocs( + [...docs].sort((a, b) => + (b.created_at ?? "").localeCompare(a.created_at ?? ""), + ), + ); + const fullProjects = await Promise.all( + projs.map((p) => getProject(p.id)), + ); + setDirectoryProjects(fullProjects); + }) + .catch(() => { + setStandaloneDocs([]); + setDirectoryProjects([]); + }) + .finally(() => setLoadingDirectory(false)); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!open) return null; + + function handleClose() { + setStep("details"); + setTitle(""); + setUnderProject(false); + setSelectedProjectId(""); + setProjectDocs([]); + setStandaloneDocs([]); + setDirectoryProjects([]); + setSelectedDocIds(new Set()); + setSelectedWorkflowId(null); + onClose(); + } + + function submitterValue(e: React.FormEvent<HTMLFormElement>) { + return ( + (e.nativeEvent as SubmitEvent).submitter as + | HTMLButtonElement + | null + )?.value; + } + + function handleSubmit(e: React.FormEvent<HTMLFormElement>) { + e.preventDefault(); + if (!title.trim()) return; + if (underProject && !selectedProjectId) return; + if (step === "details" || submitterValue(e) !== "create-review") { + setStep("documents"); + return; + } + const selectedWorkflow = workflows.find( + (w) => w.id === selectedWorkflowId, + ); + onAdd( + title.trim(), + underProject ? selectedProjectId : undefined, + selectedDocIds.size > 0 ? [...selectedDocIds] : undefined, + selectedWorkflow?.columns_config ?? undefined, + ); + handleClose(); + } + + async function handleSelectProject(projectId: string) { + setSelectedProjectId(projectId); + setProjectDocs([]); + setSelectedDocIds(new Set()); + setLoadingDocs(true); + try { + const proj = await getProject(projectId); + const docs = (proj.documents ?? []).filter( + (d) => d.status === "ready", + ); + setProjectDocs(docs); + setSelectedDocIds(new Set(docs.map((d) => d.id))); + } finally { + setLoadingDocs(false); + } + } + + 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) => + underProject && selectedProjectId + ? uploadProjectDocument(selectedProjectId, f) + : uploadStandaloneDocument(f), + ), + ); + if (underProject && selectedProjectId) { + setProjectDocs((prev) => [...uploaded, ...prev]); + } else { + setStandaloneDocs((prev) => [...uploaded, ...prev]); + } + uploaded.forEach((d) => + setSelectedDocIds((prev) => new Set([...prev, d.id])), + ); + } catch (err) { + console.error("Upload failed:", err); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } + + const workflowOptions = [ + { + value: "", + label: loadingWorkflows + ? "Loading templates..." + : "No template - start from scratch", + }, + ...workflows.map((workflow) => ({ + value: workflow.id, + label: workflow.metadata.title, + })), + ]; + const projectOptions = projects.length + ? projects.map((project) => ({ + value: project.id, + label: + project.name + + (project.cm_number ? ` (#${project.cm_number})` : ""), + })) + : [{ value: "", label: "No projects found" }]; + + // What to show in the directory depends on mode and toggle state + const directoryStandalone = isProjectMode + ? (fixedProjectDocs ?? []) + : underProject + ? [] + : standaloneDocs; + const directoryFolders = isProjectMode + ? [] + : underProject + ? [] + : directoryProjects; + const flatProjectDocs: Document[] = + !isProjectMode && underProject ? projectDocs : []; + const directoryLoading = isProjectMode + ? false + : underProject + ? loadingDocs + : loadingDirectory; + const showDirectory = isProjectMode || !underProject || !!selectedProjectId; + const breadcrumbs = + isProjectMode && projectName + ? [ + "Projects", + `${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`, + "New Tabular Review", + ] + : ["Tabular Reviews", "New Tabular Review"]; + + return ( + <Modal + open={open} + onClose={handleClose} + breadcrumbs={[ + ...breadcrumbs, + step === "details" ? "Details" : "Add Documents", + ]} + secondaryAction={ + step === "documents" + ? { + 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, + } + : undefined + } + cancelAction={ + step === "documents" + ? { + label: "Back", + onClick: () => setStep("details"), + disabled: uploading, + } + : undefined + } + primaryAction={ + step === "details" + ? { + label: "Next", + type: "button", + onClick: (event) => { + event.preventDefault(); + setStep("documents"); + }, + disabled: + !title.trim() || + (underProject && !selectedProjectId), + } + : { + label: "Create", + type: "submit", + form: formId, + name: "modalAction", + value: "create-review", + disabled: + !title.trim() || + (underProject && !selectedProjectId), + } + } + > + <input + ref={fileInputRef} + type="file" + accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt" + multiple + className="hidden" + onChange={handleUpload} + /> + <form + id={formId} + onSubmit={handleSubmit} + className="flex flex-col min-h-0 flex-1" + > + {step === "details" ? ( + <div className="space-y-6"> + <div> + <ModalFieldLabel htmlFor="new-tr-title"> + Review name + </ModalFieldLabel> + <ModalTextInput + id="new-tr-title" + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Review name" + variant="minimal" + className="placeholder:text-gray-400" + autoFocus + /> + </div> + + {/* Workflow template */} + <div> + <ModalFieldLabel as="p"> + Workflow template + </ModalFieldLabel> + <ModalSelect + id="new-tr-workflow-template" + value={selectedWorkflowId ?? ""} + options={workflowOptions} + onChange={(value) => + setSelectedWorkflowId(value || null) + } + disabled={loadingWorkflows} + /> + </div> + + {/* Create under a project toggle */} + {!isProjectMode && ( + <div className="space-y-3"> + <ModalFieldLabel as="p"> + Project + </ModalFieldLabel> + <button + type="button" + onClick={() => { + const next = !underProject; + setUnderProject(next); + if (!next) { + setSelectedProjectId(""); + setProjectDocs([]); + setSelectedDocIds(new Set()); + } + }} + className="flex w-fit items-center gap-2.5" + > + <span + className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${underProject ? "bg-gray-900" : "bg-gray-100"}`} + > + <span + className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${underProject ? "translate-x-4" : "translate-x-0"}`} + /> + </span> + <span className="text-sm text-gray-600"> + Create under a project + </span> + </button> + + {underProject && ( + <ModalSelect + id="new-tr-project" + value={selectedProjectId} + options={projectOptions} + onChange={(value) => { + if (value) { + void handleSelectProject(value); + } + }} + placeholder="Select project..." + disabled={projects.length === 0} + /> + )} + </div> + )} + </div> + ) : ( + <div className="flex min-h-0 flex-1 flex-col"> + {showDirectory && ( + <FileDirectory + standaloneDocs={ + isProjectMode + ? directoryStandalone + : underProject + ? flatProjectDocs + : directoryStandalone + } + directoryProjects={ + isProjectMode + ? [] + : underProject + ? [] + : directoryFolders + } + loading={directoryLoading} + selectedIds={selectedDocIds} + onChange={setSelectedDocIds} + emptyMessage={ + isProjectMode || underProject + ? "No ready documents in this project" + : "No documents yet" + } + searchable + searchAutoFocus + showProjectTabs={!isProjectMode && !underProject} + /> + )} + </div> + )} + </form> + </Modal> + ); +} diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 5c5ffc2..be9af54 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -13,7 +13,7 @@ import { ChevronLeft, Trash2, } from "lucide-react"; -import { MikeIcon } from "@/components/chat/mike-icon"; +import { MikeIcon } from "@/app/components/chat/mike-icon"; import { streamTabularChat, getTabularChats, @@ -25,16 +25,16 @@ import { } from "@/app/lib/mikeApi"; import type { AssistantEvent, ColumnConfig, Document } from "../shared/types"; import { ModelToggle } from "../assistant/ModelToggle"; -import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal"; -import { PreResponseWrapper } from "../shared/PreResponseWrapper"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup"; +import { PreResponseWrapper } from "../assistant/PreResponseWrapper"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { getModelProvider, isModelAvailable, type ModelProvider, } from "@/app/lib/modelAvailability"; import type { ApiKeyState } from "@/app/lib/mikeApi"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; // --------------------------------------------------------------------------- // Types @@ -1444,9 +1444,7 @@ export function TRChatPanel({ continue; } - if ( - data.type === "courtlistener_find_in_case_start" - ) { + if (data.type === "courtlistener_find_in_case_start") { const searches = parseCourtlistenerCaseSearches( data.searches, ); @@ -1472,8 +1470,7 @@ export function TRChatPanel({ ); updateMatchingEvent( (e) => - e.type === - "courtlistener_find_in_case" && + e.type === "courtlistener_find_in_case" && (searches?.length ? Array.isArray(e.searches) : e.cluster_id === @@ -1481,8 +1478,7 @@ export function TRChatPanel({ "number" ? (data.cluster_id as number) : null) && - e.query === - (data.query as string)) && + e.query === (data.query as string)) && !!e.isStreaming, () => ({ type: "courtlistener_find_in_case", @@ -1917,7 +1913,7 @@ export function TRChatPanel({ onHeightChange={setInputHeight} /> - <ApiKeyMissingModal + <ApiKeyMissingPopup open={apiKeyModalProvider !== null} provider={apiKeyModalProvider} onClose={() => setApiKeyModalProvider(null)} diff --git a/frontend/src/app/components/tabular/TREditColumnMenu.tsx b/frontend/src/app/components/tabular/TREditColumnMenu.tsx index efe2da0..0edb226 100644 --- a/frontend/src/app/components/tabular/TREditColumnMenu.tsx +++ b/frontend/src/app/components/tabular/TREditColumnMenu.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; -import { ChevronDown, Loader2, MoreHorizontal, Plus, Trash2, X } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChevronDown, Loader2, MoreHorizontal, Plus, X } from "lucide-react"; import type { ColumnConfig, ColumnFormat } from "../shared/types"; import { generateTabularColumnPrompt } from "@/app/lib/mikeApi"; import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat"; @@ -12,10 +13,17 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +} from "@/app/components/ui/dropdown-menu"; +import { PillButton } from "@/app/components/ui/pill-button"; + +// Liquid-glass field styling shared by the menu's inputs/controls, matching the +// modal's glass treatment (translucent white over the light-gray panel). +const GLASS_FIELD = + "border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl"; export interface TREditColumnMenuProps { column: ColumnConfig; + closeSignal?: number; disabled?: boolean; onSave: (column: ColumnConfig) => void | Promise<void>; onDelete: (columnIndex: number) => void | Promise<void>; @@ -23,11 +31,13 @@ export interface TREditColumnMenuProps { export function TREditColumnMenu({ column, + closeSignal, disabled, onSave, onDelete, }: TREditColumnMenuProps) { const [open, setOpen] = useState(false); + const menuId = useId(); const [name, setName] = useState(column.name); const [prompt, setPrompt] = useState(column.prompt); const [format, setFormat] = useState<ColumnFormat>(column.format ?? "text"); @@ -36,6 +46,58 @@ export function TREditColumnMenu({ const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [generating, setGenerating] = useState(false); + const buttonRef = useRef<HTMLButtonElement>(null); + const panelRef = useRef<HTMLDivElement>(null); + // Fixed-position coords for the portaled menu. The menu is rendered into + // document.body so it isn't clipped by the header's overflow-hidden (which + // exists for horizontal scroll-sync). We size/position it to span the column + // header cell (with a min width for usability) and keep it pinned as the + // table scrolls or the window resizes. + const [menuPos, setMenuPos] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + + useEffect(() => { + if (!open) { + setMenuPos(null); + return; + } + const update = () => { + const rect = buttonRef.current?.getBoundingClientRect(); + if (!rect) return; + // Span this column's header cell so the panel's left/right edges line + // up with the column. Falls back to the trigger button when no column + // ancestor is found. A min width keeps the form usable on narrow + // columns (there it just extends leftward from the column's right + // edge). Positioning via left (same coordinate space as + // getBoundingClientRect) avoids scrollbar/innerWidth offsets. + const colRect = buttonRef.current + ?.closest("[data-tr-col-header]") + ?.getBoundingClientRect(); + const rightEdge = colRect?.right ?? rect.right; + const width = Math.max(colRect?.width ?? 288, 288); // 288 = w-72 + setMenuPos({ + top: rect.bottom + 6, // mt-1.5 + left: Math.max(8, rightEdge - width), + width, + }); + }; + update(); + window.addEventListener("scroll", update, true); + window.addEventListener("resize", update); + return () => { + window.removeEventListener("scroll", update, true); + window.removeEventListener("resize", update); + }; + }, [open]); + + useEffect(() => { + if (closeSignal === undefined) return; + const timeout = window.setTimeout(() => setOpen(false), 0); + return () => window.clearTimeout(timeout); + }, [closeSignal]); useEffect(() => { if (!open) { @@ -47,6 +109,45 @@ export function TREditColumnMenu({ } }, [column.name, column.prompt, column.format, column.tags, open]); + // Only one edit-column menu should be open at a time. Broadcast when this + // one opens and close ourselves when another one broadcasts. + useEffect(() => { + if (open) { + window.dispatchEvent( + new CustomEvent("tr-edit-column-menu-open", { detail: menuId }), + ); + } + }, [open, menuId]); + + useEffect(() => { + const onOtherOpen = (e: Event) => { + if ((e as CustomEvent<string>).detail !== menuId) setOpen(false); + }; + window.addEventListener("tr-edit-column-menu-open", onOtherOpen); + return () => + window.removeEventListener("tr-edit-column-menu-open", onOtherOpen); + }, [menuId]); + + // Close on click outside the panel. Ignore the trigger (it toggles) and any + // Radix popper (e.g. the Format dropdown, which portals outside the panel). + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + const target = e.target as Node; + if ( + panelRef.current?.contains(target) || + buttonRef.current?.contains(target) || + (target instanceof Element && + target.closest("[data-radix-popper-content-wrapper]")) + ) { + return; + } + setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [open]); + function commitTag() { const tag = tagInput.trim(); if (!tag) { @@ -112,6 +213,7 @@ export function TREditColumnMenu({ return ( <div className="relative shrink-0" onClick={(e) => e.stopPropagation()}> <button + ref={buttonRef} onClick={(e) => { e.stopPropagation(); if (disabled) return; @@ -127,19 +229,28 @@ export function TREditColumnMenu({ <MoreHorizontal className="h-4 w-4" /> </button> - {open && ( - <div - className="absolute right-0 top-full z-20 mt-1.5 w-72 rounded-2xl border border-white/70 bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl" - onClick={(e) => e.stopPropagation()} - > + {open && + menuPos && + createPortal( + <div + ref={panelRef} + className="fixed z-[40] rounded-3xl border border-white/70 bg-gray-50/95 p-3 shadow-[0_14px_40px_rgba(15,23,42,0.071),0_5px_14px_rgba(15,23,42,0.047)] backdrop-blur-3xl" + style={{ + top: menuPos.top, + left: menuPos.left, + width: menuPos.width, + }} + onClick={(e) => e.stopPropagation()} + > <div className="flex items-center justify-between mb-3"> - <p className="text-sm font-medium text-gray-800"> + <p className="font-serif text-lg font-medium text-gray-900"> Edit Column </p> <button type="button" onClick={() => setOpen(false)} - className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors" + aria-label="Close" + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700" > <X className="h-3.5 w-3.5" /> </button> @@ -151,7 +262,7 @@ export function TREditColumnMenu({ type="text" value={name} onChange={(e) => setName(e.target.value)} - className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-gray-800 text-xs font-normal focus:border-gray-400 focus:outline-none" + className={`mt-1 w-full rounded-lg px-2 py-1 text-xs font-normal text-gray-800 transition-colors focus:bg-white/70 focus:outline-none ${GLASS_FIELD}`} /> {/* Format */} @@ -161,7 +272,9 @@ export function TREditColumnMenu({ </label> <DropdownMenu> <DropdownMenuTrigger asChild> - <button className="mt-1 flex w-full items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 hover:border-gray-400 focus:outline-none"> + <button + className={`mt-1 flex w-full items-center justify-between rounded-lg px-2 py-1 text-xs text-gray-700 transition-colors hover:bg-white/75 focus:outline-none ${GLASS_FIELD}`} + > <span className="flex items-center gap-1.5"> {(() => { const Icon = formatIcon(format); @@ -176,6 +289,7 @@ export function TREditColumnMenu({ </DropdownMenuTrigger> <DropdownMenuContent align="start" + className="z-[50] border-white/70 bg-white/75 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl" style={{ width: "var(--radix-dropdown-menu-trigger-width)", }} @@ -206,7 +320,9 @@ export function TREditColumnMenu({ {/* Tag input */} {format === "tag" && ( <div className="mt-2"> - <div className="flex flex-wrap gap-1 rounded-md border border-gray-200 px-2 py-1 focus-within:border-gray-400 min-h-[28px]"> + <div + className={`flex min-h-[28px] flex-wrap gap-1 rounded-lg px-2 py-1 transition-colors focus-within:bg-white/70 ${GLASS_FIELD}`} + > {tags.map((tag, tagIdx) => ( <span key={tag} @@ -269,20 +385,18 @@ export function TREditColumnMenu({ rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} - className="mt-2 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-normal text-gray-800 placeholder-gray-300 focus:border-gray-400 focus:outline-none resize-none leading-relaxed" + className={`mt-2 w-full resize-none rounded-lg px-3 py-2 text-xs font-normal leading-relaxed text-gray-800 placeholder-gray-300 transition-colors focus:bg-white/70 focus:outline-none ${GLASS_FIELD}`} /> </div> <div className="mt-3 flex items-center justify-between gap-2"> - <button - type="button" + <PillButton + tone="danger" onClick={handleDelete} disabled={deleting || saving} - className="inline-flex items-center gap-1.5 text-xs text-red-500 transition-colors hover:text-red-600 disabled:text-red-300" > - <Trash2 className="h-3.5 w-3.5" /> Delete - </button> + </PillButton> <button type="button" onClick={handleSave} @@ -298,8 +412,9 @@ export function TREditColumnMenu({ {saving ? "Saving…" : "Save"} </button> </div> - </div> - )} + </div>, + document.body, + )} </div> ); } diff --git a/frontend/src/app/components/tabular/TRSidePanel.tsx b/frontend/src/app/components/tabular/TRSidePanel.tsx index 9bcac49..e6d6412 100644 --- a/frontend/src/app/components/tabular/TRSidePanel.tsx +++ b/frontend/src/app/components/tabular/TRSidePanel.tsx @@ -17,11 +17,13 @@ import type { Document, TabularCell, } from "../shared/types"; +import { isSpreadsheetFilename } from "../shared/types"; import { preprocessCitations, type ParsedCitation } from "./citation-utils"; import { getPillClass } from "./pillUtils"; -import { DocView } from "../shared/DocView"; -import { DocxView } from "../shared/DocxView"; -import { cn } from "@/lib/utils"; +import { PdfView } from "../shared/views/PdfView"; +import { SpreadsheetView } from "../shared/views/SpreadsheetView"; +import { DocxView } from "../shared/views/DocxView"; +import { cn } from "@/app/lib/utils"; function isDocxDocument(d: { file_type?: string | null; @@ -176,8 +178,10 @@ export function TRSidePanel({ }, ]} /> + ) : isSpreadsheetFilename(doc.filename ?? "") ? ( + <SpreadsheetView documentId={doc.id} /> ) : ( - <DocView + <PdfView doc={{ document_id: doc.id }} quote={docCitation.quote} fallbackPage={docCitation.page} diff --git a/frontend/src/app/components/tabular/TRTable.tsx b/frontend/src/app/components/tabular/TRTable.tsx index 2181a9a..6f528e8 100644 --- a/frontend/src/app/components/tabular/TRTable.tsx +++ b/frontend/src/app/components/tabular/TRTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { forwardRef, useImperativeHandle, useRef } from "react"; +import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { Loader2, Plus, Table2, Upload } from "lucide-react"; import type { ColumnConfig, @@ -74,12 +74,32 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( ) { const stickyCellBg = "bg-[#fafbfc]"; const scrollContainerRef = useRef<HTMLDivElement>(null); + const headerRef = useRef<HTMLDivElement>(null); + const lastScrollLeftRef = useRef(0); + const [scrollCloseSignal, setScrollCloseSignal] = useState(0); + + function syncHeader() { + if (headerRef.current && scrollContainerRef.current) { + headerRef.current.scrollLeft = scrollContainerRef.current.scrollLeft; + } + } + + function handleRowsScroll() { + syncHeader(); + const container = scrollContainerRef.current; + if (!container) return; + + if (container.scrollLeft !== lastScrollLeftRef.current) { + lastScrollLeftRef.current = container.scrollLeft; + setScrollCloseSignal((signal) => signal + 1); + } + } + const sortedColumns = [...columns].sort((a, b) => a.index - b.index); const totalContentWidth = DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32; const skeletonContentWidth = DOC_COL_W_PX + SKELETON_COLS * DATA_COL_W_PX + 32; - useImperativeHandle(ref, () => ({ scrollToCell(colIdx: number, rowIdx: number) { const container = scrollContainerRef.current; @@ -139,48 +159,52 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( return ( <div className="flex flex-1 flex-col overflow-hidden"> {/* Header */} - <div - className={`flex h-8 ${stickyCellBg}`} - style={{ minWidth: skeletonContentWidth }} - > + <div className="shrink-0 overflow-hidden"> <div - className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`} - > - <SkeletonDot /> - <span>Document</span> - </div> - {Array.from({ length: SKELETON_COLS }).map((_, i) => ( - <div - key={i} - className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2`} - > - <SkeletonLine className="h-4 w-28" /> - </div> - ))} - <div className="flex-1 border-b border-gray-200 min-w-8" /> - </div> - {/* Rows */} - {Array.from({ length: SKELETON_ROWS }).map((_, row) => ( - <div - key={row} - className={`flex h-10 ${row % 2 === 0 ? stickyCellBg : "bg-gray-50"}`} + className={`flex h-8 ${stickyCellBg}`} style={{ minWidth: skeletonContentWidth }} > - <div className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2`}> + <div + className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`} + > <SkeletonDot /> - <SkeletonLine className="h-4 w-32" /> + <span>Document</span> </div> - {Array.from({ length: SKELETON_COLS }).map((_, col) => ( + {Array.from({ length: SKELETON_COLS }).map((_, i) => ( <div - key={col} + key={i} className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2`} > - <SkeletonLine className="h-4" /> + <SkeletonLine className="h-4 w-28" /> </div> ))} <div className="flex-1 border-b border-gray-200 min-w-8" /> </div> - ))} + </div> + {/* Rows */} + <div className="flex flex-1 flex-col overflow-auto min-h-0"> + {Array.from({ length: SKELETON_ROWS }).map((_, row) => ( + <div + key={row} + className={`flex h-10 ${row % 2 === 0 ? stickyCellBg : "bg-gray-50"}`} + style={{ minWidth: skeletonContentWidth }} + > + <div className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2`}> + <SkeletonDot /> + <SkeletonLine className="h-4 w-32" /> + </div> + {Array.from({ length: SKELETON_COLS }).map((_, col) => ( + <div + key={col} + className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2`} + > + <SkeletonLine className="h-4" /> + </div> + ))} + <div className="flex-1 border-b border-gray-200 min-w-8" /> + </div> + ))} + </div> </div> ); } @@ -192,7 +216,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( ) { return ( <div className="flex flex-1 flex-col overflow-hidden"> - <div className="flex items-center border-b border-gray-200"> + <div className={`shrink-0 flex items-center border-b border-gray-200 ${stickyCellBg}`}> <div className={`${DOC_COL_W} border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500 select-none`} > @@ -234,57 +258,63 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( } return ( - <div - className="flex flex-1 flex-col overflow-auto" - ref={scrollContainerRef} - > + <div className="flex flex-1 flex-col overflow-hidden"> {/* Header */} - <div - className={`sticky top-0 z-20 flex h-8 ${stickyCellBg}`} - style={{ minWidth: totalContentWidth }} - > + <div ref={headerRef} className="shrink-0 overflow-hidden"> <div - className={`sticky left-0 z-30 ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 flex items-center gap-4 py-2 pl-4 pr-2 text-left text-xs font-medium text-gray-500 select-none`} + className={`flex h-8 ${stickyCellBg}`} + style={{ minWidth: totalContentWidth }} > - <input - type="checkbox" - checked={allSelected} - ref={(el) => { - if (el) el.indeterminate = someSelected; - }} - onChange={toggleAll} - className={TABLE_CHECKBOX_CLASS} - /> - <span>Document</span> - </div> - {columns.map((col) => ( <div - key={col.index} - className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`} + className={`sticky left-0 z-[80] ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 flex items-center gap-4 py-2 pl-4 pr-2 text-left text-xs font-medium text-gray-500 select-none`} > - <div className="flex items-center justify-between gap-3"> - <span className="truncate">{col.name}</span> - <TREditColumnMenu - column={col} - disabled={savingColumn || savingColumnsConfig} - onSave={onUpdateColumn} - onDelete={onDeleteColumn} - /> - </div> + <input + type="checkbox" + checked={allSelected} + ref={(el) => { + if (el) el.indeterminate = someSelected; + }} + onChange={toggleAll} + className={TABLE_CHECKBOX_CLASS} + /> + <span>Document</span> + </div> + {columns.map((col) => ( + <div + key={col.index} + data-tr-col-header + className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`} + > + <div className="flex items-center justify-between gap-3"> + <span className="truncate">{col.name}</span> + <TREditColumnMenu + column={col} + closeSignal={scrollCloseSignal} + disabled={savingColumn || savingColumnsConfig} + onSave={onUpdateColumn} + onDelete={onDeleteColumn} + /> + </div> + </div> + ))} + <div className="flex-1 border-b border-gray-200 flex items-center justify-start p-2 min-w-8"> + <button + onClick={onAddColumn} + disabled={savingColumn || savingColumnsConfig} + className="flex items-center justify-center text-gray-400 hover:text-gray-700 transition-colors disabled:text-gray-200" + > + <Plus className="h-4 w-4" /> + </button> </div> - ))} - <div className="flex-1 border-b border-gray-200 flex items-center justify-start p-2 min-w-8"> - <button - onClick={onAddColumn} - disabled={savingColumn || savingColumnsConfig} - className="flex items-center justify-center text-gray-400 hover:text-gray-700 transition-colors disabled:text-gray-200" - > - <Plus className="h-4 w-4" /> - </button> </div> </div> {/* Rows */} + <div + className="flex flex-1 flex-col overflow-auto min-h-0" + ref={scrollContainerRef} + onScroll={handleRowsScroll} + > <div className="relative min-h-0 flex-1"> {dragOverFiles && ( <div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" /> @@ -364,6 +394,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( <TabularCellComponent cell={cell} column={col} + closeSignal={scrollCloseSignal} onExpand={() => onExpand(cell)} onCitationClick={( page, @@ -385,6 +416,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( ); })} </div> + </div> </div> ); }); diff --git a/frontend/src/app/components/tabular/TabularCell.tsx b/frontend/src/app/components/tabular/TabularCell.tsx index 39c329d..0b7e7ab 100644 --- a/frontend/src/app/components/tabular/TabularCell.tsx +++ b/frontend/src/app/components/tabular/TabularCell.tsx @@ -12,6 +12,7 @@ import { SkeletonLine } from "../shared/TablePrimitive"; interface Props { cell: TCell; column?: ColumnConfig; + closeSignal?: number; onExpand: () => void; onCitationClick?: (page: number, quote: string) => void; } @@ -158,12 +159,19 @@ function CellMarkdown({ export function TabularCell({ cell, column, + closeSignal, onExpand, onCitationClick, }: Props) { const [inlineExpanded, setInlineExpanded] = useState(false); const containerRef = useRef<HTMLDivElement>(null); + useEffect(() => { + if (closeSignal === undefined) return; + const timeout = window.setTimeout(() => setInlineExpanded(false), 0); + return () => window.clearTimeout(timeout); + }, [closeSignal]); + useEffect(() => { if (!inlineExpanded) return; function handleClickOutside(e: MouseEvent) { @@ -240,7 +248,7 @@ export function TabularCell({ {/* Inline expanded overlay — absolutely positioned so it overlays without disrupting table layout */} {inlineExpanded && ( - <div className="absolute left-0 top-0 z-50 w-full bg-white border border-gray-200 shadow-lg rounded-sm"> + <div className="absolute left-0 top-0 z-50 w-full rounded-xl bg-gray-50/95 shadow-[0_4px_12px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl"> <div className="relative p-2 pr-4 text-xs text-gray-800 leading-relaxed"> {cell.content.flag && ( <span diff --git a/frontend/src/app/components/tabular/TabularReviewDetailsModal.tsx b/frontend/src/app/components/tabular/TabularReviewDetailsModal.tsx new file mode 100644 index 0000000..b7c7377 --- /dev/null +++ b/frontend/src/app/components/tabular/TabularReviewDetailsModal.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Modal } from "../modals/Modal"; +import { ModalFieldLabel } from "../modals/ModalFieldLabel"; +import { ModalSelect } from "../modals/ModalSelect"; +import { ModalTextInput } from "../modals/ModalTextInput"; +import type { Project, TabularReview } from "../shared/types"; + +interface TabularReviewDetailsModalProps { + open: boolean; + review: TabularReview | null; + projects: Project[]; + canEdit: boolean; + lockProject?: boolean; + onClose: () => void; + onSave: (values: { + title: string; + projectId?: string | null; + }) => Promise<void>; +} + +export function TabularReviewDetailsModal({ + open, + review, + projects, + canEdit, + lockProject = false, + onClose, + onSave, +}: TabularReviewDetailsModalProps) { + const [titleDraft, setTitleDraft] = useState(""); + const [underProject, setUnderProject] = useState(false); + const [selectedProjectId, setSelectedProjectId] = useState(""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (!open || !review) return; + setTitleDraft(review.title ?? ""); + setUnderProject(Boolean(review.project_id)); + setSelectedProjectId(review.project_id ?? ""); + setSaving(false); + setSaved(false); + setError(null); + }, [open, review]); + + const trimmedTitle = titleDraft.trim(); + const nextProjectId = underProject ? selectedProjectId : null; + const projectOptions = useMemo( + () => + projects.length + ? projects.map((project) => ({ + value: project.id, + label: + project.name + + (project.cm_number ? ` (#${project.cm_number})` : ""), + })) + : [{ value: "", label: "No projects found" }], + [projects], + ); + const hasChanges = useMemo(() => { + if (!review) return false; + return ( + trimmedTitle !== (review.title ?? "") || + nextProjectId !== (review.project_id ?? null) + ); + }, [nextProjectId, review, trimmedTitle]); + + if (!review) return null; + + async function handleSave() { + if ( + !canEdit || + saving || + !hasChanges || + !trimmedTitle || + (underProject && !selectedProjectId) + ) { + return; + } + setSaving(true); + setSaved(false); + setError(null); + try { + await onSave({ + title: trimmedTitle, + projectId: nextProjectId, + }); + setSaved(true); + } catch { + setError("Could not update tabular review details."); + } finally { + setSaving(false); + } + } + + return ( + <Modal + open={open} + onClose={onClose} + breadcrumbs={[ + "Tabular Reviews", + review.title || "Untitled Review", + "Details", + ]} + footerStatus={ + error ? ( + <span className="text-sm text-red-600">{error}</span> + ) : saved ? ( + <span className="text-sm text-gray-400">Updated</span> + ) : null + } + primaryAction={ + canEdit + ? { + label: saving ? "Saving..." : "Save changes", + onClick: () => void handleSave(), + disabled: + saving || + !hasChanges || + !trimmedTitle || + (underProject && !selectedProjectId), + } + : undefined + } + cancelAction={canEdit ? undefined : false} + > + <div className="space-y-6"> + <div> + <ModalFieldLabel htmlFor="tabular-review-details-title"> + Review name + </ModalFieldLabel> + <ModalTextInput + id="tabular-review-details-title" + type="text" + value={titleDraft} + onChange={(event) => { + setTitleDraft(event.target.value); + setSaved(false); + setError(null); + }} + placeholder="Review name" + variant="minimal" + className="placeholder:text-gray-400" + disabled={!canEdit || saving} + autoFocus + /> + </div> + + {!lockProject && ( + <div className="space-y-3"> + <ModalFieldLabel as="p">Project</ModalFieldLabel> + <button + type="button" + onClick={() => { + if (!canEdit || saving) return; + const next = !underProject; + setUnderProject(next); + if (!next) setSelectedProjectId(""); + setSaved(false); + setError(null); + }} + className="flex w-fit items-center gap-2.5" + > + <span + className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${ + underProject ? "bg-gray-900" : "bg-gray-100" + }`} + > + <span + className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${ + underProject + ? "translate-x-4" + : "translate-x-0" + }`} + /> + </span> + <span className="text-sm text-gray-600"> + Move under a project + </span> + </button> + + {underProject && ( + <ModalSelect + id="tabular-review-details-project" + value={selectedProjectId} + options={projectOptions} + onChange={(value) => { + setSelectedProjectId(value); + setSaved(false); + setError(null); + }} + placeholder="Select project..." + disabled={ + !canEdit || saving || projects.length === 0 + } + /> + )} + </div> + )} + </div> + </Modal> + ); +} diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index ebbbeaf..2b1565d 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -23,6 +23,7 @@ import { getTabularReview, getProject, getTabularReviewPeople, + listProjects, regenerateTabularCell, streamTabularGeneration, updateTabularReview, @@ -38,15 +39,15 @@ import type { } from "../shared/types"; import { AddColumnModal } from "./AddColumnModal"; import { TRWorkflowModal } from "./TRWorkflowModal"; -import { AddDocumentsModal } from "../shared/AddDocumentsModal"; -import { AddProjectDocsModal } from "../shared/AddProjectDocsModal"; -import { PeopleModal } from "../shared/PeopleModal"; -import { OwnerOnlyModal } from "../shared/OwnerOnlyModal"; -import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal"; -import { ConfirmPopup } from "../shared/ConfirmPopup"; +import { AddDocumentsModal } from "../modals/AddDocumentsModal"; +import { AddProjectDocsModal } from "../modals/AddProjectDocsModal"; +import { PeopleModal } from "../modals/PeopleModal"; +import { OwnerOnlyPopup } from "../popups/OwnerOnlyPopup"; +import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup"; +import { ConfirmPopup } from "../popups/ConfirmPopup"; import { HeaderActionsMenu } from "../shared/HeaderActionsMenu"; -import { useAuth } from "@/contexts/AuthContext"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { getModelProvider, isModelAvailable, @@ -56,6 +57,7 @@ import { TRSidePanel } from "./TRSidePanel"; import { TRTable } from "./TRTable"; import type { TRTableHandle } from "./TRTable"; import { TRChatPanel } from "./TRChatPanel"; +import { TabularReviewDetailsModal } from "./TabularReviewDetailsModal"; import { exportTabularReviewToExcel } from "./exportToExcel"; import { useSidebar } from "@/app/contexts/SidebarContext"; import { PageHeader } from "../shared/PageHeader"; @@ -79,6 +81,8 @@ export function TRView({ reviewId, projectId }: Props) { const [savingColumnsConfig, setSavingColumnsConfig] = useState(false); const [addColOpen, setAddColOpen] = useState(false); const [addDocsOpen, setAddDocsOpen] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + const [availableProjects, setAvailableProjects] = useState<Project[]>([]); const [peopleModalOpen, setPeopleModalOpen] = useState(false); const [workflowModalOpen, setWorkflowModalOpen] = useState(false); const [applyingWorkflow, setApplyingWorkflow] = useState(false); @@ -161,6 +165,12 @@ export function TRView({ reviewId, projectId }: Props) { .then(setProject) .catch(() => {}), ); + } else { + fetches.push( + listProjects() + .then(setAvailableProjects) + .catch(() => setAvailableProjects([])), + ); } Promise.all(fetches).finally(() => setLoading(false)); }, [reviewId, projectId]); @@ -546,28 +556,40 @@ export function TRView({ reviewId, projectId }: Props) { await clearResultsForDocuments(documents.map((document) => document.id)); } - async function handleTitleCommit(newTitle: string) { - if (!newTitle || newTitle === review?.title) return; + function requestReviewDetails() { if (review?.is_owner === false) { - setOwnerOnlyAction("rename this tabular review"); + setOwnerOnlyAction("edit tabular review details"); return; } - setReview((prev) => (prev ? { ...prev, title: newTitle } : prev)); - await updateTabularReview(reviewId, { title: newTitle }); + setDetailsOpen(true); } - function requestReviewRename() { - if (review?.is_owner === false) { - setOwnerOnlyAction("rename this tabular review"); + async function handleDetailsSave(values: { + title: string; + projectId?: string | null; + }) { + if (!review || review.is_owner === false) { + setOwnerOnlyAction("edit tabular review details"); return; } - const nextTitle = window.prompt( - "Rename tabular review", - review?.title ?? "Untitled Review", + const updated = await updateTabularReview(reviewId, { + title: values.title, + project_id: values.projectId ?? null, + }); + setReview((prev) => + prev + ? { + ...prev, + ...updated, + } + : updated, ); - const trimmed = nextTitle?.trim(); - if (!trimmed) return; - void handleTitleCommit(trimmed); + if (!projectId && updated.project_id) { + setDetailsOpen(false); + router.push( + `/projects/${updated.project_id}/tabular-reviews/${reviewId}`, + ); + } } function requestReviewDelete() { @@ -716,9 +738,9 @@ export function TRView({ reviewId, projectId }: Props) { <HeaderActionsMenu items={[ { - label: "Rename", + label: "Edit details", icon: Pencil, - onSelect: requestReviewRename, + onSelect: requestReviewDetails, }, { label: "Apply workflow", @@ -1046,6 +1068,16 @@ export function TRView({ reviewId, projectId }: Props) { /> )} + <TabularReviewDetailsModal + open={detailsOpen} + review={review} + projects={project ? [project] : availableProjects} + canEdit={review?.is_owner !== false} + lockProject={Boolean(projectId)} + onClose={() => setDetailsOpen(false)} + onSave={handleDetailsSave} + /> + <PeopleModal open={peopleModalOpen} onClose={() => setPeopleModalOpen(false)} @@ -1124,13 +1156,13 @@ export function TRView({ reviewId, projectId }: Props) { onConfirm={() => void confirmReviewDelete()} /> - <OwnerOnlyModal + <OwnerOnlyPopup open={!!ownerOnlyAction} action={ownerOnlyAction ?? undefined} onClose={() => setOwnerOnlyAction(null)} /> - <ApiKeyMissingModal + <ApiKeyMissingPopup open={apiKeyModalProvider !== null} provider={apiKeyModalProvider} onClose={() => setApiKeyModalProvider(null)} diff --git a/frontend/src/app/components/tabular/columnFormat.ts b/frontend/src/app/components/tabular/columnFormat.ts index bfa1daa..10dc4a0 100644 --- a/frontend/src/app/components/tabular/columnFormat.ts +++ b/frontend/src/app/components/tabular/columnFormat.ts @@ -2,16 +2,16 @@ import type { LucideIcon } from "lucide-react"; import { AlignLeft, List, Hash, DollarSign, ToggleLeft, Calendar, Tag, Percent, Banknote } from "lucide-react"; import type { ColumnFormat } from "../shared/types"; -export const FORMAT_OPTIONS: Array<{ value: ColumnFormat; label: string; icon: LucideIcon }> = [ - { value: "text", label: "Free Text", icon: AlignLeft }, - { value: "bulleted_list", label: "Bulleted list", icon: List }, - { value: "number", label: "Number", icon: Hash }, - { value: "percentage", label: "Percentage", icon: Percent }, - { value: "monetary_amount", label: "Monetary Amount", icon: Banknote }, - { value: "currency", label: "Currency", icon: DollarSign }, - { value: "yes_no", label: "Yes / No", icon: ToggleLeft }, - { value: "date", label: "Date", icon: Calendar }, - { value: "tag", label: "Tags", icon: Tag }, +export const FORMAT_OPTIONS: Array<{ value: ColumnFormat; label: string; icon: LucideIcon; iconClassName: string }> = [ + { value: "text", label: "Free Text", icon: AlignLeft, iconClassName: "text-sky-500" }, + { value: "bulleted_list", label: "Bulleted list", icon: List, iconClassName: "text-indigo-500" }, + { value: "number", label: "Number", icon: Hash, iconClassName: "text-violet-500" }, + { value: "percentage", label: "Percentage", icon: Percent, iconClassName: "text-fuchsia-500" }, + { value: "monetary_amount", label: "Monetary Amount", icon: Banknote, iconClassName: "text-emerald-600" }, + { value: "currency", label: "Currency", icon: DollarSign, iconClassName: "text-teal-600" }, + { value: "yes_no", label: "Yes / No", icon: ToggleLeft, iconClassName: "text-amber-500" }, + { value: "date", label: "Date", icon: Calendar, iconClassName: "text-rose-500" }, + { value: "tag", label: "Tags", icon: Tag, iconClassName: "text-orange-500" }, ]; export function formatLabel(format: ColumnFormat): string { @@ -21,3 +21,7 @@ export function formatLabel(format: ColumnFormat): string { export function formatIcon(format: ColumnFormat): LucideIcon { return FORMAT_OPTIONS.find((o) => o.value === format)?.icon ?? AlignLeft; } + +export function formatIconClassName(format: ColumnFormat): string { + return FORMAT_OPTIONS.find((o) => o.value === format)?.iconClassName ?? "text-sky-500"; +} diff --git a/frontend/src/app/components/tabular/prompt-generator.ts b/frontend/src/app/components/tabular/prompt-generator.ts deleted file mode 100644 index 76b6441..0000000 --- a/frontend/src/app/components/tabular/prompt-generator.ts +++ /dev/null @@ -1,60 +0,0 @@ -const PROMPT_PRESETS: Array<{ - matches: RegExp; - prompt: (title: string) => string; -}> = [ - { - matches: /\bpart(y|ies)\b/i, - prompt: () => - 'Identify all parties referenced in the document. List their full names and describe each party\'s role or capacity in the agreement. If a party is not clearly identified, state "Not addressed".', - }, - { - matches: /\bchange of control\b/i, - prompt: () => - 'Identify any change of control provisions in the document. Summarize the trigger, the consequences, any consent requirements, and any related termination, acceleration, or notice obligations. If not addressed, state "Not addressed".', - }, - { - matches: /\bterminat(e|ion|ing)\b/i, - prompt: () => - 'Extract the termination provisions in the document. Summarize who may terminate, the termination triggers, any notice requirements, cure periods, and the consequences of termination. If not addressed, state "Not addressed".', - }, - { - matches: /\bgoverning law\b|\bjurisdiction\b/i, - prompt: () => - 'Identify the governing law and jurisdiction provisions in the document. State the governing law, the forum for disputes, and any submission to jurisdiction or venue requirements. If not addressed, state "Not addressed".', - }, - { - matches: /\bconfidential(ity)?\b|\bnon-?disclosure\b/i, - prompt: () => - 'Extract the confidentiality provisions in the document. Summarize the scope of confidential information, permitted disclosures, use restrictions, duration, and any carve-outs or exceptions. If not addressed, state "Not addressed".', - }, - { - matches: /\bassign(ment|ability)?\b/i, - prompt: () => - 'Identify any assignment provisions in the document. Summarize whether assignment is permitted, restricted, or requires consent, and note any exceptions or deemed assignments. If not addressed, state "Not addressed".', - }, - { - matches: /\bpayment\b|\bfees?\b/i, - prompt: () => - 'Extract the payment and fee terms in the document. Summarize payment obligations, amounts, timing, currencies, fee types, and any consequences for late or missed payment. If not addressed, state "Not addressed".', - }, -]; - -export function getPresetTabularPrompt(title: string): string | null { - const trimmedTitle = title.trim(); - if (!trimmedTitle) return null; - - const preset = PROMPT_PRESETS.find(({ matches }) => matches.test(trimmedTitle)); - return preset ? preset.prompt(trimmedTitle) : null; -} - -export function buildFallbackTabularPrompt(title: string): string { - const trimmedTitle = title.trim(); - if (!trimmedTitle) return ""; - - return ( - `Review each document and extract the information relevant to "${trimmedTitle}". ` + - `Provide a concise, document-specific summary for this column. ` + - `Include the key facts, dates, thresholds, parties, and conditions where applicable. ` + - `If the document does not contain relevant information, return "Not addressed".` - ); -} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/app/components/ui/button.tsx similarity index 98% rename from frontend/src/components/ui/button.tsx rename to frontend/src/app/components/ui/button.tsx index 21409a0..1099220 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/app/components/ui/button.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils" +import { cn } from "@/app/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", diff --git a/frontend/src/components/ui/cite-button.tsx b/frontend/src/app/components/ui/cite-button.tsx similarity index 100% rename from frontend/src/components/ui/cite-button.tsx rename to frontend/src/app/components/ui/cite-button.tsx diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/app/components/ui/dropdown-menu.tsx similarity index 99% rename from frontend/src/components/ui/dropdown-menu.tsx rename to frontend/src/app/components/ui/dropdown-menu.tsx index 5176e9e..355db3b 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/app/components/ui/dropdown-menu.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { CheckIcon, ChevronRightIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/app/lib/utils"; function DropdownMenu({ ...props diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/app/components/ui/input.tsx similarity index 96% rename from frontend/src/components/ui/input.tsx rename to frontend/src/app/components/ui/input.tsx index 8916905..817a5b3 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/app/components/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react" -import { cn } from "@/lib/utils" +import { cn } from "@/app/lib/utils" function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( diff --git a/frontend/src/app/components/ui/pill-button.tsx b/frontend/src/app/components/ui/pill-button.tsx new file mode 100644 index 0000000..88b477d --- /dev/null +++ b/frontend/src/app/components/ui/pill-button.tsx @@ -0,0 +1,50 @@ +"use client"; + +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "@/app/lib/utils"; + +type PillButtonTone = "black" | "white" | "blue" | "danger"; +type PillButtonSize = "sm" | "normal"; + +type PillButtonProps = React.ComponentProps<"button"> & { + asChild?: boolean; + tone: PillButtonTone; + size?: PillButtonSize; +}; + +const toneClasses: Record<PillButtonTone, string> = { + black: "border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-1px_0_rgba(255,255,255,0.12),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 disabled:hover:bg-gray-950/88", + white: "border-transparent bg-transparent text-gray-700 shadow-[0_1px_2px_rgba(0,0,0,0.10),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(255,255,255,0.7)] hover:bg-gray-100 disabled:hover:bg-transparent", + blue: "border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(255,255,255,0.16),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 disabled:hover:bg-blue-600/90", + danger: "border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.10),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-1px_0_rgba(255,255,255,0.14),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 disabled:hover:bg-red-600/90", +}; + +const sizeClasses: Record<PillButtonSize, string> = { + sm: "px-2 py-1 text-xs", + normal: "px-4 py-1.5 text-sm", +}; + +export function PillButton({ + asChild = false, + tone, + size = "sm", + type = "button", + className, + ...props +}: PillButtonProps) { + const Comp = asChild ? Slot : "button"; + + return ( + <Comp + type={asChild ? undefined : type} + className={cn( + "inline-flex items-center justify-center gap-1.5 rounded-full border font-medium transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100", + toneClasses[tone], + sizeClasses[size], + className, + )} + {...props} + /> + ); +} diff --git a/frontend/src/app/components/ui/search-bar.tsx b/frontend/src/app/components/ui/search-bar.tsx new file mode 100644 index 0000000..94d7454 --- /dev/null +++ b/frontend/src/app/components/ui/search-bar.tsx @@ -0,0 +1,102 @@ +"use client"; + +import * as React from "react"; +import { Search, X } from "lucide-react"; +import { cn } from "@/app/lib/utils"; + +type SearchBarSize = "sm" | "normal"; + +type SearchBarProps = Omit< + React.InputHTMLAttributes<HTMLInputElement>, + "onChange" | "size" | "type" | "value" +> & { + value: string; + onValueChange: (value: string) => void; + size?: SearchBarSize; + clearLabel?: string; + wrapperClassName?: string; + inputClassName?: string; +}; + +const sizeClasses: Record< + SearchBarSize, + { wrapper: string; input: string; icon: string; clear: string } +> = { + sm: { + wrapper: "h-8 gap-1.5 rounded-lg px-2.5", + input: "text-xs", + icon: "h-3 w-3", + clear: "h-5 w-5", + }, + normal: { + wrapper: "h-9 gap-2 rounded-xl px-3", + input: "text-sm", + icon: "h-3.5 w-3.5", + clear: "h-6 w-6", + }, +}; + +export const SearchBar = React.forwardRef<HTMLInputElement, SearchBarProps>( + ( + { + value, + onValueChange, + size = "normal", + clearLabel = "Clear search", + placeholder = "Search...", + className, + wrapperClassName, + inputClassName, + ...props + }, + ref, + ) => { + const classes = sizeClasses[size]; + + return ( + <div + className={cn( + "flex items-center border border-white/70 bg-white/55 text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors focus-within:border-white/90 focus-within:bg-white/70", + classes.wrapper, + className, + wrapperClassName, + )} + > + <Search + className={cn( + "shrink-0 text-gray-400", + classes.icon, + )} + /> + <input + ref={ref} + type="search" + value={value} + placeholder={placeholder} + onChange={(event) => onValueChange(event.target.value)} + className={cn( + "min-w-0 flex-1 bg-transparent text-gray-700 outline-none placeholder:text-gray-400 [&::-webkit-search-cancel-button]:hidden", + classes.input, + inputClassName, + )} + {...props} + /> + {value ? ( + <button + type="button" + onClick={() => onValueChange("")} + className={cn( + "flex shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-white/70 hover:text-gray-600", + classes.clear, + )} + aria-label={clearLabel} + > + <X className={classes.icon} /> + </button> + ) : null} + </div> + ); + }, +); + +SearchBar.displayName = "SearchBar"; diff --git a/frontend/src/app/components/workflows/DisplayWorkflowModal.tsx b/frontend/src/app/components/workflows/DisplayWorkflowModal.tsx deleted file mode 100644 index 54a6b2c..0000000 --- a/frontend/src/app/components/workflows/DisplayWorkflowModal.tsx +++ /dev/null @@ -1,577 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Folder, Search, X } from "lucide-react"; -import type { Document, Workflow } from "../shared/types"; -import { createTabularReview } from "@/app/lib/mikeApi"; -import { useRouter } from "next/navigation"; -import { useDirectoryData } from "../shared/useDirectoryData"; -import { FileDirectory } from "../shared/FileDirectory"; -import type { Project } from "../shared/types"; -import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; -import { Modal } from "../shared/Modal"; -import { WorkflowPickerContent } from "./WorkflowPickerContent"; -import { workflowDetailPath } from "./workflowRoutes"; - -interface Props { - workflows: Workflow[]; - workflow: Workflow | null; - onClose: () => void; -} - -// --------------------------------------------------------------------------- -// Toggle switch -// --------------------------------------------------------------------------- -function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) { - return ( - <button - type="button" - onClick={onToggle} - className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${on ? "bg-gray-900" : "bg-gray-200"}`} - > - <span - className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${on ? "translate-x-4" : "translate-x-0"}`} - /> - </button> - ); -} - -// --------------------------------------------------------------------------- -// Simple project picker (input + dropdown) -// --------------------------------------------------------------------------- -function SimpleProjectPicker({ - projects, - selectedId, - onSelect, -}: { - projects: Project[]; - selectedId: string | null; - onSelect: (id: string | null) => void; -}) { - const [search, setSearch] = useState(""); - const [open, setOpen] = useState(false); - const selected = projects.find((p) => p.id === selectedId); - const filtered = search - ? projects.filter((p) => - p.name.toLowerCase().includes(search.toLowerCase()), - ) - : projects; - - return ( - <div className="relative"> - <input - type="text" - value={selectedId ? (selected?.name ?? "") : search} - onChange={(e) => { - setSearch(e.target.value); - setOpen(true); - onSelect(null); - }} - onFocus={() => setOpen(true)} - onBlur={() => setTimeout(() => setOpen(false), 150)} - placeholder="Select a project…" - className="w-full text-xs text-gray-700 placeholder:text-gray-400 bg-gray-50 border border-gray-200 rounded-md px-3 py-2 outline-none" - /> - {selectedId && ( - <button - onMouseDown={() => { - onSelect(null); - setSearch(""); - }} - className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" - > - <X className="h-3 w-3" /> - </button> - )} - {open && !selectedId && ( - <div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-sm overflow-y-auto max-h-40"> - {filtered.length === 0 ? ( - <p className="px-3 py-3 text-xs text-gray-400 text-center"> - No projects found - </p> - ) : ( - filtered.map((p) => ( - <button - key={p.id} - onMouseDown={() => { - onSelect(p.id); - setSearch(""); - setOpen(false); - }} - className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-gray-50 text-gray-700" - > - <Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" /> - {p.name} - </button> - )) - )} - </div> - )} - </div> - ); -} - -// --------------------------------------------------------------------------- -// DisplayWorkflowModal -// --------------------------------------------------------------------------- -export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) { - const [screen, setScreen] = useState<"select" | "configure">("select"); - const [selected, setSelected] = useState<Workflow | null>(workflow); - const [listSearch, setListSearch] = useState(""); - - // Configure screen state - const [inProject, setInProject] = useState(false); - const [selectedProjectId, setSelectedProjectId] = useState<string | null>( - null, - ); - const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>( - new Set(), - ); - const [docSearch, setDocSearch] = useState(""); - const [assistantPrompt, setAssistantPrompt] = useState(""); - const [saving, setSaving] = useState(false); - - const router = useRouter(); - const { saveChat, setNewChatMessages } = useChatHistoryContext(); - const { - loading: dirLoading, - projects, - standaloneDocuments, - } = useDirectoryData(screen === "configure"); - - useEffect(() => { - if (workflow) { - setSelected(workflow); - setScreen("select"); - setListSearch(""); - } else { - setSelected(null); - } - }, [workflow?.id]); - - // Reset configure state on back - useEffect(() => { - if (screen === "select") { - setInProject(false); - setSelectedProjectId(null); - setSelectedDocIds(new Set()); - setDocSearch(""); - setAssistantPrompt(""); - } - }, [screen]); - - function handleClose() { - setSelected(null); - setScreen("select"); - onClose(); - } - - if (!workflow) return null; - const wf = selected ?? workflow; - - // --------------------------------------------------------------------------- - // Handlers - // --------------------------------------------------------------------------- - async function handleStartChat() { - setSaving(true); - try { - const projectId = inProject ? selectedProjectId! : undefined; - const chatId = await saveChat(projectId); - if (!chatId) return; - const allDocs: Document[] = [ - ...standaloneDocuments, - ...projects.flatMap((p) => p.documents || []), - ]; - const files = allDocs - .filter((d) => selectedDocIds.has(d.id)) - .map((d) => ({ - filename: d.filename, - document_id: d.id, - })); - const content = assistantPrompt.trim() - ? `implement workflow\n\n${assistantPrompt.trim()}` - : "implement workflow"; - setNewChatMessages([ - { - role: "user", - content, - files: files.length > 0 ? files : undefined, - }, - ]); - handleClose(); - router.push( - projectId - ? `/projects/${projectId}/assistant/chat/${chatId}` - : `/assistant/chat/${chatId}`, - ); - } finally { - setSaving(false); - } - } - - async function handleCreateReview() { - const allDocs: Document[] = [ - ...standaloneDocuments, - ...projects.flatMap((p) => p.documents || []), - ]; - const docIds = allDocs - .filter((d) => selectedDocIds.has(d.id)) - .map((d) => d.id); - const projectId = inProject ? selectedProjectId! : undefined; - - setSaving(true); - try { - const review = await createTabularReview({ - title: wf.title, - document_ids: docIds, - columns_config: wf.columns_config || [], - workflow_id: wf.is_system ? undefined : wf.id, - project_id: projectId, - }); - handleClose(); - router.push( - projectId - ? `/projects/${projectId}/tabular-reviews/${review.id}` - : `/tabular-reviews/${review.id}`, - ); - } finally { - setSaving(false); - } - } - - // --------------------------------------------------------------------------- - // Tabular doc browser helpers - // --------------------------------------------------------------------------- - const q = docSearch.toLowerCase().trim(); - const selectedProject = projects.find((p) => p.id === selectedProjectId); - const projectDocs = selectedProject?.documents ?? []; - - const filteredProjectDocs = q - ? projectDocs.filter((d) => - d.filename.toLowerCase().includes(q), - ) - : projectDocs; - - const filteredStandalone = q - ? standaloneDocuments.filter((d) => - d.filename.toLowerCase().includes(q), - ) - : standaloneDocuments; - - const filteredAllProjects = projects - .map((p) => ({ - ...p, - documents: (p.documents || []).filter( - (d) => - !q || d.filename.toLowerCase().includes(q), - ), - })) - .filter( - (p) => - !q || - p.name.toLowerCase().includes(q) || - p.documents.length > 0, - ); - - const breadcrumbs = - screen === "select" - ? ["Workflows", "Select workflow"] - : [ - <button - key="workflows" - type="button" - onClick={() => setScreen("select")} - className="transition-colors hover:text-gray-700" - > - Workflows - </button>, - wf.title, - wf.type === "assistant" ? "New Chat" : "New Review", - ]; - - const selectPageAction = () => { - router.push(workflowDetailPath(wf)); - handleClose(); - }; - - // --------------------------------------------------------------------------- - // Render - // --------------------------------------------------------------------------- - return ( - <Modal - open={!!workflow} - onClose={handleClose} - size={screen === "select" ? "xl" : "lg"} - breadcrumbs={breadcrumbs} - secondaryAction={ - screen === "select" - ? { - label: wf.is_system ? "View Page" : "Edit", - onClick: selectPageAction, - } - : undefined - } - footerStatus={ - screen === "configure" && - (wf.type === "assistant" - ? !inProject && selectedDocIds.size > 0 - : selectedDocIds.size > 0) ? ( - <span className="text-xs text-gray-400"> - {selectedDocIds.size} selected - </span> - ) : null - } - primaryAction={ - screen === "select" - ? { - label: "Use", - onClick: () => setScreen("configure"), - } - : wf.type === "assistant" - ? { - label: saving ? "Starting…" : "Start Chat", - onClick: handleStartChat, - disabled: - saving || (inProject && !selectedProjectId), - } - : { - label: saving ? "Creating…" : "Create Review", - onClick: handleCreateReview, - disabled: - saving || - selectedDocIds.size === 0 || - (inProject && !selectedProjectId), - } - } - cancelAction={false} - > - - {/* ── SELECT SCREEN ── */} - {screen === "select" && ( - <WorkflowPickerContent - workflows={workflows} - selected={wf} - onSelect={(next) => { - if (next) setSelected(next); - }} - search={listSearch} - onSearchChange={setListSearch} - workflowType="all" - previewMode="auto" - showTypeIcon - allowClearPreview={false} - /> - )} - - {/* ── ASSISTANT CONFIGURE SCREEN ── */} - {screen === "configure" && wf.type === "assistant" && ( - <div className="flex-1 min-h-0 flex flex-col overflow-hidden"> - {/* Add-on prompt */} - <div className="pb-3 shrink-0"> - <p className="text-xs font-medium text-gray-700 mb-2"> - Message (optional) - </p> - <textarea - rows={3} - value={assistantPrompt} - onChange={(e) => - setAssistantPrompt(e.target.value) - } - placeholder="Add any additional instructions to the workflow prompt…" - className="w-full text-sm text-gray-700 placeholder:text-gray-400 bg-gray-50 border border-gray-200 rounded-md px-3 py-2 resize-none outline-none leading-relaxed" - /> - </div> - - {/* Toggle row */} - <div className="py-3 flex flex-col gap-2 shrink-0"> - <span className="text-xs font-medium text-gray-700"> - Create in a project - </span> - <Toggle - on={inProject} - onToggle={() => { - setInProject(!inProject); - setSelectedProjectId(null); - setSelectedDocIds(new Set()); - setDocSearch(""); - }} - /> - </div> - - {inProject ? ( - <> - <div className="pt-1 pb-1 shrink-0"> - <p className="text-xs font-medium text-gray-700"> - Select project - </p> - </div> - <div className="pb-2 shrink-0"> - <SimpleProjectPicker - projects={projects} - selectedId={selectedProjectId} - onSelect={setSelectedProjectId} - /> - </div> - </> - ) : ( - <> - <div className="pt-1 pb-1 shrink-0"> - <p className="text-xs font-medium text-gray-700"> - Select documents - </p> - </div> - - {/* Search */} - <div className="pt-1.5 pb-1 shrink-0"> - <div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1"> - <Search className="h-3 w-3 text-gray-400 shrink-0" /> - <input - type="text" - placeholder="Search…" - value={docSearch} - onChange={(e) => - setDocSearch(e.target.value) - } - className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none" - /> - {docSearch && ( - <button - onClick={() => - setDocSearch("") - } - className="text-gray-400 hover:text-gray-600" - > - <X className="h-3 w-3" /> - </button> - )} - </div> - </div> - - {/* File browser */} - <div className="flex-1 overflow-y-auto pb-2"> - <FileDirectory - standaloneDocs={filteredStandalone} - directoryProjects={ - filteredAllProjects - } - loading={dirLoading} - selectedIds={selectedDocIds} - onChange={setSelectedDocIds} - allowMultiple - forceExpanded={!!q} - emptyMessage={ - q - ? "No matches found" - : "No documents yet" - } - /> - </div> - </> - )} - </div> - )} - - {/* ── TABULAR CONFIGURE SCREEN ── */} - {screen === "configure" && wf.type === "tabular" && ( - <div className="flex-1 min-h-0 flex flex-col overflow-hidden"> - {/* Toggle stacked */} - <div className="pb-3 flex flex-col gap-2 shrink-0"> - <span className="text-xs font-medium text-gray-700"> - Create in a project - </span> - <Toggle - on={inProject} - onToggle={() => { - setInProject(!inProject); - setSelectedProjectId(null); - setDocSearch(""); - setSelectedDocIds(new Set()); - }} - /> - </div> - - {/* Project section */} - {inProject && ( - <> - <div className="pt-1 pb-1 shrink-0"> - <p className="text-xs font-medium text-gray-700"> - Select Project - </p> - </div> - <div className="pb-2 shrink-0"> - <SimpleProjectPicker - projects={projects} - selectedId={selectedProjectId} - onSelect={(id) => { - setSelectedProjectId(id); - if (!id) - setSelectedDocIds( - new Set(), - ); - }} - /> - </div> - </> - )} - - {/* Documents section */} - <div className="pt-3 pb-1 shrink-0"> - <p className="text-xs font-medium text-gray-700"> - Select Documents - </p> - </div> - - {/* Search */} - <div className="pt-1.5 pb-1 shrink-0"> - <div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1"> - <Search className="h-3 w-3 text-gray-400 shrink-0" /> - <input - type="text" - placeholder="Search…" - value={docSearch} - onChange={(e) => - setDocSearch(e.target.value) - } - className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none" - /> - {docSearch && ( - <button - onClick={() => setDocSearch("")} - className="text-gray-400 hover:text-gray-600" - > - <X className="h-3 w-3" /> - </button> - )} - </div> - </div> - - {/* File browser */} - <div className="flex-1 overflow-y-auto pb-2"> - <FileDirectory - standaloneDocs={ - inProject - ? filteredProjectDocs - : filteredStandalone - } - directoryProjects={ - inProject ? [] : filteredAllProjects - } - loading={dirLoading} - selectedIds={selectedDocIds} - onChange={setSelectedDocIds} - allowMultiple - forceExpanded={!!q || inProject} - emptyMessage={ - q - ? "No matches found" - : inProject - ? "No documents in this project" - : "No documents yet" - } - /> - </div> - </div> - )} - </Modal> - ); -} diff --git a/frontend/src/app/components/workflows/NewWorkflowModal.tsx b/frontend/src/app/components/workflows/NewWorkflowModal.tsx index 82e44f1..8f7a955 100644 --- a/frontend/src/app/components/workflows/NewWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/NewWorkflowModal.tsx @@ -1,11 +1,163 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { MessageSquare, Table2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { MessageSquare, Table2, Upload } from "lucide-react"; import { createWorkflow, updateWorkflow } from "@/app/lib/mikeApi"; import type { Workflow } from "../shared/types"; import { PRACTICE_OPTIONS } from "./practices"; -import { Modal } from "../shared/Modal"; +import { Modal } from "../modals/Modal"; +import { ModalFieldLabel } from "../modals/ModalFieldLabel"; +import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle"; +import { ModalSelect } from "../modals/ModalSelect"; +import { ModalTextInput } from "../modals/ModalTextInput"; + +const DEFAULT_LANGUAGE = "English"; +const DEFAULT_PRACTICE = "General Transactions"; +const DEFAULT_JURISDICTION = "General"; +const LANGUAGE_OPTIONS = [ + "English", + "Chinese", + "Spanish", + "French", + "German", + "Japanese", + "Korean", + "Portuguese", + "Italian", + "Dutch", + "Arabic", + "Hebrew", + "Persian", + "Urdu", + "Hindi", + "Bengali", + "Tamil", + "Telugu", + "Indonesian", + "Malay", + "Filipino", + "Vietnamese", + "Thai", + "Burmese", + "Khmer", + "Lao", + "Russian", + "Ukrainian", + "Turkish", + "Polish", + "Czech", + "Romanian", + "Greek", + "Danish", + "Finnish", + "Norwegian", + "Swedish", + "Afrikaans", + "Swahili", + "Other", +] as const; +const JURISDICTION_OPTIONS = [ + "General", + "United States", + "England and Wales", + "European Union", + "Singapore", + "Hong Kong", + "Australia", + "Canada", + "India", + "Malaysia", + "Indonesia", + "Philippines", + "Thailand", + "Vietnam", + "Japan", + "South Korea", + "China", + "Taiwan", + "Germany", + "France", + "Netherlands", + "Ireland", + "Scotland", + "Luxembourg", + "Switzerland", + "Cayman Islands", + "British Virgin Islands", + "United Arab Emirates", + "Saudi Arabia", + "Brazil", + "Mexico", + "Other", +] as const; +const US_STATE_OPTIONS = [ + "Alabama", + "Alaska", + "Arizona", + "Arkansas", + "California", + "Colorado", + "Connecticut", + "Delaware", + "Florida", + "Georgia", + "Hawaii", + "Idaho", + "Illinois", + "Indiana", + "Iowa", + "Kansas", + "Kentucky", + "Louisiana", + "Maine", + "Maryland", + "Massachusetts", + "Michigan", + "Minnesota", + "Mississippi", + "Missouri", + "Montana", + "Nebraska", + "Nevada", + "New Hampshire", + "New Jersey", + "New Mexico", + "New York", + "North Carolina", + "North Dakota", + "Ohio", + "Oklahoma", + "Oregon", + "Pennsylvania", + "Rhode Island", + "South Carolina", + "South Dakota", + "Tennessee", + "Texas", + "Utah", + "Vermont", + "Virginia", + "Washington", + "West Virginia", + "Wisconsin", + "Wyoming", + "District of Columbia", +] as const; +const CANADA_PROVINCE_OPTIONS = [ + "Alberta", + "British Columbia", + "Manitoba", + "New Brunswick", + "Newfoundland and Labrador", + "Northwest Territories", + "Nova Scotia", + "Nunavut", + "Ontario", + "Prince Edward Island", + "Quebec", + "Saskatchewan", + "Yukon", +] as const; interface Props { open: boolean; @@ -15,42 +167,166 @@ interface Props { onUpdated?: (workflow: Workflow) => void; } -export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpdated }: Props) { +export function NewWorkflowModal({ + open, + onClose, + onCreated, + editWorkflow, + onUpdated, +}: Props) { const [title, setTitle] = useState(""); const [type, setType] = useState<"assistant" | "tabular">("assistant"); - const [practice, setPractice] = useState<string>(""); + const [language, setLanguage] = useState(DEFAULT_LANGUAGE); + const [customLanguage, setCustomLanguage] = useState(""); + const [practice, setPractice] = useState<string>(DEFAULT_PRACTICE); const [customPractice, setCustomPractice] = useState(""); + const [jurisdiction, setJurisdiction] = useState(DEFAULT_JURISDICTION); + const [jurisdictionRegion, setJurisdictionRegion] = useState(""); + const [customJurisdiction, setCustomJurisdiction] = useState(""); + const [openDropdown, setOpenDropdown] = useState< + "language" | "practice" | "jurisdiction" | "jurisdictionRegion" | null + >(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [importedSkillMd, setImportedSkillMd] = useState(""); + const [importedSkillName, setImportedSkillName] = useState<string | null>( + null, + ); + const [markdownImportError, setMarkdownImportError] = useState(""); + const customLanguageInputRef = useRef<HTMLInputElement>(null); const customInputRef = useRef<HTMLInputElement>(null); + const customJurisdictionInputRef = useRef<HTMLInputElement>(null); + const markdownInputRef = useRef<HTMLInputElement>(null); const isEditing = !!editWorkflow; - const isOthers = practice === "Others"; - const effectivePractice = isOthers ? (customPractice.trim() || null) : (practice || null); + const isOtherLanguage = language === "Other"; + const isOtherPractice = practice === "Other"; + const isOtherJurisdiction = jurisdiction === "Other"; + const effectiveLanguage = isOtherLanguage + ? customLanguage.trim() + : language.trim(); + const effectivePractice = isOtherPractice ? (customPractice.trim() || null) : (practice || null); + const effectiveJurisdiction = isOtherJurisdiction + ? customJurisdiction.trim() + : jurisdictionRegion.trim() || jurisdiction; + const languageOptions = ( + (LANGUAGE_OPTIONS as readonly string[]).includes(language) + ? LANGUAGE_OPTIONS + : [language, ...LANGUAGE_OPTIONS] + ).filter(Boolean); + const baseJurisdictionOptions = + (JURISDICTION_OPTIONS as readonly string[]).includes(jurisdiction) + ? JURISDICTION_OPTIONS + : [jurisdiction, ...JURISDICTION_OPTIONS]; + const jurisdictionOptions = baseJurisdictionOptions.filter(Boolean); + const jurisdictionRegionOptions = + jurisdiction === "United States" + ? US_STATE_OPTIONS + : jurisdiction === "Canada" + ? CANADA_PROVINCE_OPTIONS + : []; + const effectiveJurisdictions = effectiveJurisdiction + .split(",") + .map((item) => item.trim()) + .filter(Boolean); const formId = "workflow-modal-form"; + const resetForm = useCallback(() => { + setTitle(""); + setType("assistant"); + setLanguage(DEFAULT_LANGUAGE); + setCustomLanguage(""); + setPractice(DEFAULT_PRACTICE); + setCustomPractice(""); + setJurisdiction(DEFAULT_JURISDICTION); + setJurisdictionRegion(""); + setCustomJurisdiction(""); + setOpenDropdown(null); + setError(""); + setImportedSkillMd(""); + setImportedSkillName(null); + setMarkdownImportError(""); + if (markdownInputRef.current) { + markdownInputRef.current.value = ""; + } + }, []); + useEffect(() => { if (open && editWorkflow) { - setTitle(editWorkflow.title); - setType(editWorkflow.type); - const saved = editWorkflow.practice ?? ""; + setTitle(editWorkflow.metadata.title); + setType(editWorkflow.metadata.type); + const savedLanguage = + editWorkflow.metadata.language ?? DEFAULT_LANGUAGE; + const isKnownLanguage = (LANGUAGE_OPTIONS as readonly string[]).includes(savedLanguage); + if (!isKnownLanguage && savedLanguage) { + setLanguage("Other"); + setCustomLanguage(savedLanguage); + } else { + setLanguage(savedLanguage); + setCustomLanguage(""); + } + const savedJurisdiction = editWorkflow.metadata.jurisdictions?.length + ? editWorkflow.metadata.jurisdictions.join(", ") + : DEFAULT_JURISDICTION; + const isKnownJurisdiction = + (JURISDICTION_OPTIONS as readonly string[]).includes(savedJurisdiction); + const isUsState = (US_STATE_OPTIONS as readonly string[]).includes( + savedJurisdiction, + ); + const isCanadaProvince = ( + CANADA_PROVINCE_OPTIONS as readonly string[] + ).includes(savedJurisdiction); + if (!isKnownJurisdiction && savedJurisdiction) { + if (isUsState) { + setJurisdiction("United States"); + setJurisdictionRegion(savedJurisdiction); + setCustomJurisdiction(""); + } else if (isCanadaProvince) { + setJurisdiction("Canada"); + setJurisdictionRegion(savedJurisdiction); + setCustomJurisdiction(""); + } else { + setJurisdiction("Other"); + setJurisdictionRegion(""); + setCustomJurisdiction(savedJurisdiction); + } + } else { + setJurisdiction(savedJurisdiction); + setJurisdictionRegion(""); + setCustomJurisdiction(""); + } + const saved = editWorkflow.metadata.practice ?? DEFAULT_PRACTICE; const isKnown = (PRACTICE_OPTIONS as readonly string[]).includes(saved); if (!isKnown && saved) { - setPractice("Others"); + setPractice("Other"); setCustomPractice(saved); } else { setPractice(saved); setCustomPractice(""); } setError(""); + } else if (open) { + resetForm(); } - }, [open, editWorkflow?.id]); + }, [open, editWorkflow, resetForm]); useEffect(() => { - if (isOthers) { + if (isOtherLanguage) { + customLanguageInputRef.current?.focus(); + } + }, [isOtherLanguage]); + + useEffect(() => { + if (isOtherPractice) { customInputRef.current?.focus(); } - }, [isOthers]); + }, [isOtherPractice]); + + useEffect(() => { + if (isOtherJurisdiction) { + customJurisdictionInputRef.current?.focus(); + } + }, [isOtherJurisdiction]); if (!open) return null; @@ -62,16 +338,32 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd try { if (isEditing && editWorkflow) { const updated = await updateWorkflow(editWorkflow.id, { - title: title.trim(), - practice: effectivePractice, + metadata: { + title: title.trim(), + language: effectiveLanguage || null, + practice: effectivePractice, + jurisdictions: effectiveJurisdictions.length + ? effectiveJurisdictions + : null, + }, }); onUpdated?.(updated); } else { - const workflow = await createWorkflow({ - title: title.trim(), - type, - practice: effectivePractice, - }); + const createPayload: Parameters<typeof createWorkflow>[0] = { + metadata: { + title: title.trim(), + type, + language: effectiveLanguage || null, + practice: effectivePractice, + jurisdictions: effectiveJurisdictions.length + ? effectiveJurisdictions + : null, + }, + }; + if (type === "assistant" && importedSkillMd) { + createPayload.skill_md = importedSkillMd; + } + const workflow = await createWorkflow(createPayload); onCreated(workflow); } resetForm(); @@ -83,19 +375,42 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd } } - function resetForm() { - setTitle(""); - setType("assistant"); - setPractice(""); - setCustomPractice(""); - setError(""); - } - function handleClose() { resetForm(); onClose(); } + async function handleMarkdownImport( + e: React.ChangeEvent<HTMLInputElement>, + ) { + const file = e.target.files?.[0]; + setMarkdownImportError(""); + if (!file) return; + + const normalizedName = file.name.toLowerCase(); + if ( + !normalizedName.endsWith(".md") && + !normalizedName.endsWith(".markdown") + ) { + setImportedSkillMd(""); + setImportedSkillName(null); + setMarkdownImportError("Choose a .md or .markdown file."); + e.target.value = ""; + return; + } + + try { + const text = await file.text(); + setImportedSkillMd(text); + setImportedSkillName(file.name); + } catch { + setImportedSkillMd(""); + setImportedSkillName(null); + setMarkdownImportError("Could not read that markdown file."); + e.target.value = ""; + } + } + return ( <Modal open={open} @@ -116,86 +431,223 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd form: formId, disabled: !title.trim() || loading, }} + secondaryAction={ + !isEditing && type === "assistant" + ? { + label: importedSkillName ?? "Upload markdown", + icon: <Upload className="h-3.5 w-3.5" />, + onClick: () => markdownInputRef.current?.click(), + disabled: loading, + } + : undefined + } > <form id={formId} onSubmit={handleSubmit} - className="flex flex-col flex-1 min-h-0" + className="flex min-h-0 flex-1 flex-col" > - <input - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - placeholder="Workflow name" - className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent" - autoFocus - /> + <div className="space-y-6"> + <div> + <ModalFieldLabel htmlFor="workflow-title"> + Title + </ModalFieldLabel> + <ModalTextInput + id="workflow-title" + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Add workflow name" + variant="minimal" + autoFocus + /> + </div> - {!isEditing && ( - <div className="mt-5"> - <p className="mb-2 text-sm font-medium text-gray-500">Type</p> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={() => setType("assistant")} - className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${ - type === "assistant" - ? "border-gray-900 bg-gray-900 text-white" - : "border-gray-200 text-gray-600 hover:bg-gray-50" - }`} - > - <MessageSquare className="h-3 w-3" /> - Assistant - </button> - <button - type="button" - onClick={() => setType("tabular")} - className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${ - type === "tabular" - ? "border-gray-900 bg-gray-900 text-white" - : "border-gray-200 text-gray-600 hover:bg-gray-50" - }`} - > - <Table2 className="h-3 w-3" /> - Tabular - </button> + {!isEditing && ( + <div> + <ModalFieldLabel as="p">Type</ModalFieldLabel> + <ModalSegmentedToggle + value={type} + onChange={setType} + options={[ + { + value: "assistant", + label: "Assistant", + icon: MessageSquare, + }, + { + value: "tabular", + label: "Tabular", + icon: Table2, + }, + ]} + /> + </div> + )} + + <div className="grid gap-5 md:grid-cols-2"> + <div> + <ModalFieldLabel htmlFor="workflow-language"> + Language + </ModalFieldLabel> + <ModalSelect + id="workflow-language" + value={language} + options={languageOptions} + open={openDropdown === "language"} + onOpenChange={(nextOpen) => + setOpenDropdown((current) => + nextOpen + ? "language" + : current === "language" + ? null + : current, + ) + } + onChange={(value) => { + setLanguage(value); + if (value !== "Other") { + setCustomLanguage(""); + } + setOpenDropdown(null); + }} + /> + {isOtherLanguage && ( + <ModalTextInput + ref={customLanguageInputRef} + type="text" + value={customLanguage} + onChange={(e) => + setCustomLanguage(e.target.value) + } + placeholder="Enter language…" + className="mt-2" + /> + )} + </div> + + <div> + <ModalFieldLabel htmlFor="workflow-practice"> + Practice area + </ModalFieldLabel> + <ModalSelect + id="workflow-practice" + value={practice} + options={PRACTICE_OPTIONS} + open={openDropdown === "practice"} + onOpenChange={(nextOpen) => + setOpenDropdown((current) => + nextOpen + ? "practice" + : current === "practice" + ? null + : current, + ) + } + onChange={(value) => { + setPractice(value); + if (value !== "Other") { + setCustomPractice(""); + } + setOpenDropdown(null); + }} + /> + {isOtherPractice && ( + <ModalTextInput + ref={customInputRef} + type="text" + value={customPractice} + onChange={(e) => + setCustomPractice(e.target.value) + } + placeholder="Enter practice area…" + className="mt-2" + /> + )} </div> </div> - )} - <div className="mt-5"> - <p className="mb-2 text-sm font-medium text-gray-500">Practice Area</p> - <div className="flex flex-wrap gap-2"> - {PRACTICE_OPTIONS.map((p) => ( - <button - key={p} - type="button" - onClick={() => setPractice(practice === p ? "" : p)} - className={`rounded-full border px-3 py-1 text-xs transition-colors ${ - practice === p - ? "border-gray-900 bg-gray-900 text-white" - : "border-gray-200 text-gray-600 hover:bg-gray-50" - }`} - > - {p} - </button> - ))} - </div> - {isOthers && ( - <input - ref={customInputRef} - type="text" - value={customPractice} - onChange={(e) => setCustomPractice(e.target.value)} - placeholder="Enter practice area…" - className="mt-3 w-full rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none" + <div> + <ModalFieldLabel htmlFor="workflow-jurisdiction"> + Jurisdiction + </ModalFieldLabel> + <ModalSelect + id="workflow-jurisdiction" + value={jurisdiction} + options={jurisdictionOptions} + open={openDropdown === "jurisdiction"} + onOpenChange={(nextOpen) => + setOpenDropdown((current) => + nextOpen + ? "jurisdiction" + : current === "jurisdiction" + ? null + : current, + ) + } + onChange={(value) => { + setJurisdiction(value); + setJurisdictionRegion(""); + if (value !== "Other") { + setCustomJurisdiction(""); + } + setOpenDropdown(null); + }} /> + {jurisdictionRegionOptions.length > 0 && ( + <ModalSelect + id="workflow-jurisdiction-region" + className="mt-2" + value={jurisdictionRegion} + options={jurisdictionRegionOptions} + placeholder={ + jurisdiction === "United States" + ? "Select state..." + : "Select province..." + } + open={openDropdown === "jurisdictionRegion"} + onOpenChange={(nextOpen) => + setOpenDropdown((current) => + nextOpen + ? "jurisdictionRegion" + : current === "jurisdictionRegion" + ? null + : current, + ) + } + onChange={(value) => { + setJurisdictionRegion(value); + setOpenDropdown(null); + }} + /> + )} + {isOtherJurisdiction && ( + <ModalTextInput + ref={customJurisdictionInputRef} + type="text" + value={customJurisdiction} + onChange={(e) => + setCustomJurisdiction(e.target.value) + } + placeholder="Enter jurisdiction…" + className="mt-2" + /> + )} + </div> + + {(error || markdownImportError) && ( + <p className="text-sm text-red-500"> + {error || markdownImportError} + </p> )} </div> - - {error && ( - <p className="mt-4 text-sm text-red-500">{error}</p> - )} + <input + ref={markdownInputRef} + type="file" + className="hidden" + accept=".md,.markdown,text/markdown,text/x-markdown,text/plain" + onChange={handleMarkdownImport} + /> </form> </Modal> ); diff --git a/frontend/src/app/components/workflows/OpenSourceWorkflowModal.tsx b/frontend/src/app/components/workflows/OpenSourceWorkflowModal.tsx new file mode 100644 index 0000000..bfd2ccf --- /dev/null +++ b/frontend/src/app/components/workflows/OpenSourceWorkflowModal.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Check, EyeOff, User } from "lucide-react"; +import { openSourceWorkflow } from "@/app/lib/mikeApi"; +import type { WorkflowOpenSourceSubmission } from "@/app/components/shared/types"; +import { Modal } from "@/app/components/modals/Modal"; +import { ModalFieldLabel } from "@/app/components/modals/ModalFieldLabel"; +import { ModalSegmentedToggle } from "@/app/components/modals/ModalSegmentedToggle"; +import { ModalTextInput } from "@/app/components/modals/ModalTextInput"; + +type OpenSourceContributorMode = "named" | "anonymous"; +type OpenSourceStatus = "idle" | "loading" | "complete"; + +const WORKFLOWS_REPO_URL = + "https://github.com/Open-Legal-Products/mike-workflows"; + +interface OpenSourceWorkflowModalProps { + open: boolean; + onClose: () => void; + workflowId: string; + defaultContributorName: string; + pending: boolean; + onSubmitted: (submission: WorkflowOpenSourceSubmission) => void; +} + +export function OpenSourceWorkflowModal({ + open, + onClose, + workflowId, + defaultContributorName, + pending, + onSubmitted, +}: OpenSourceWorkflowModalProps) { + const [status, setStatus] = useState<OpenSourceStatus>("idle"); + const [error, setError] = useState<string | null>(null); + const [contributorMode, setContributorMode] = + useState<OpenSourceContributorMode>("anonymous"); + const [contributorName, setContributorName] = useState(""); + const [contributorOrganisation, setContributorOrganisation] = useState(""); + const [contributorRole, setContributorRole] = useState(""); + const [contributorLinkedin, setContributorLinkedin] = useState(""); + const [disclosureConsent, setDisclosureConsent] = useState(false); + const [closeCountdown, setCloseCountdown] = useState(3); + const onCloseRef = useRef(onClose); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + function resetModalState() { + setStatus("idle"); + setError(null); + setContributorMode("anonymous"); + setContributorName(""); + setContributorOrganisation(""); + setContributorRole(""); + setContributorLinkedin(""); + setDisclosureConsent(false); + setCloseCountdown(3); + } + + useEffect(() => { + if (status !== "complete") return; + + const countdownTimer = window.setInterval(() => { + setCloseCountdown((current) => Math.max(current - 1, 1)); + }, 1000); + const closeTimer = window.setTimeout(() => { + onCloseRef.current(); + resetModalState(); + }, 3000); + + return () => { + window.clearInterval(countdownTimer); + window.clearTimeout(closeTimer); + }; + }, [status]); + + const loading = status === "loading"; + const submitted = status === "complete"; + const needsConsent = contributorMode === "named" && !disclosureConsent; + + async function handleSubmit() { + setStatus("loading"); + setError(null); + try { + const response = await openSourceWorkflow(workflowId, { + contributor_mode: contributorMode, + contributor: + contributorMode === "named" + ? { + name: + contributorName.trim() || + defaultContributorName, + organisation: + contributorOrganisation.trim() || null, + role: contributorRole.trim() || null, + linkedin: contributorLinkedin.trim() || null, + } + : null, + }); + onSubmitted({ + id: response.id, + status: response.status, + submitted_at: response.submitted_at, + updated_at: response.updated_at, + reviewed_at: response.reviewed_at ?? null, + }); + setCloseCountdown(3); + setStatus("complete"); + } catch (err) { + setStatus("idle"); + setError( + err instanceof Error + ? err.message + : "Failed to submit workflow for review.", + ); + } + } + + return ( + <Modal + open={open} + onClose={() => { + if (loading) return; + onClose(); + resetModalState(); + }} + breadcrumbs={[ + "Workflows", + submitted + ? "Submitted" + : pending + ? "Update open-source submission" + : "Open source", + ]} + primaryAction={ + submitted + ? undefined + : { + label: loading + ? pending + ? "Updating..." + : "Submitting..." + : pending + ? "Update submission" + : "Submit for review", + onClick: () => void handleSubmit(), + disabled: loading || needsConsent, + } + } + > + {submitted ? ( + <div className="flex min-h-0 flex-1 flex-col items-center justify-center pb-10 text-center"> + <div className="mb-5 flex h-12 w-12 items-center justify-center rounded-full border border-emerald-200/70 bg-emerald-50/55 text-emerald-700 shadow-[0_10px_28px_rgba(16,185,129,0.16),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(16,185,129,0.12)] ring-1 ring-white/70 backdrop-blur-xl"> + <Check className="h-6 w-6" /> + </div> + <h3 className="text-2xl font-serif text-gray-950"> + Workflow submitted + </h3> + <p className="mt-3 max-w-sm text-xs leading-6 text-gray-600"> + Your workflow snapshot has been submitted for review. + You'll be notified by email if it is accepted. + </p> + <p className="mt-6 text-xs font-medium text-gray-500"> + Closing in {closeCountdown} + </p> + </div> + ) : ( + <div className="space-y-4 pb-2 text-sm text-gray-700"> + <h3 className="text-2xl font-serif text-gray-950"> + Contribute to open source legal + </h3> + <p className="leading-6 text-xs text-gray-600"> + Submit a snapshot of this workflow for review. If + accepted, it will be shared under the Apache License 2.0 + in the{" "} + <a + href={WORKFLOWS_REPO_URL} + target="_blank" + rel="noopener noreferrer" + className="font-medium text-gray-950 underline decoration-gray-300 underline-offset-4 transition-colors hover:text-gray-600" + > + Open-Legal-Products/mike-workflows + </a>{" "} + repo. You'll be notified by email if your workflow + is accepted. + </p> + {pending && ( + <p className="rounded-xl border border-white/70 bg-white/55 px-3 py-2 text-xs leading-5 text-gray-600 shadow-[0_8px_24px_rgba(15,23,42,0.07),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(255,255,255,0.55)] backdrop-blur-xl"> + You already have a pending submission. This will + replace that pending snapshot. + </p> + )} + + <div className="space-y-2 pt-2"> + <ModalFieldLabel as="p"> + Contributor attribution + </ModalFieldLabel> + <ModalSegmentedToggle + value={contributorMode} + onChange={setContributorMode} + disabled={loading} + options={[ + { + value: "anonymous", + label: "Anonymous", + icon: EyeOff, + }, + { + value: "named", + label: "Disclose details", + icon: User, + }, + ]} + /> + {contributorMode === "named" && ( + <div className="grid gap-x-4 gap-y-5 pt-4 sm:grid-cols-2"> + <div> + <ModalFieldLabel htmlFor="open-source-contributor-name"> + Full Name + </ModalFieldLabel> + <ModalTextInput + id="open-source-contributor-name" + value={contributorName} + onChange={(event) => + setContributorName( + event.target.value, + ) + } + placeholder="Jane Doe" + disabled={loading} + /> + </div> + <div> + <ModalFieldLabel htmlFor="open-source-contributor-organisation"> + Organisation + </ModalFieldLabel> + <ModalTextInput + id="open-source-contributor-organisation" + value={contributorOrganisation} + onChange={(event) => + setContributorOrganisation( + event.target.value, + ) + } + placeholder="Acme LLP" + disabled={loading} + /> + </div> + <div> + <ModalFieldLabel htmlFor="open-source-contributor-role"> + Role + </ModalFieldLabel> + <ModalTextInput + id="open-source-contributor-role" + value={contributorRole} + onChange={(event) => + setContributorRole( + event.target.value, + ) + } + placeholder="Senior Associate" + disabled={loading} + /> + </div> + <div> + <ModalFieldLabel htmlFor="open-source-contributor-linkedin"> + LinkedIn + </ModalFieldLabel> + <ModalTextInput + id="open-source-contributor-linkedin" + type="url" + value={contributorLinkedin} + onChange={(event) => + setContributorLinkedin( + event.target.value, + ) + } + placeholder="https://linkedin.com/in/janedoe" + disabled={loading} + /> + </div> + <div className="flex items-start gap-2 pt-1 sm:col-span-2"> + <input + id="open-source-disclosure-consent" + type="checkbox" + checked={disclosureConsent} + onChange={(event) => + setDisclosureConsent( + event.target.checked, + ) + } + disabled={loading} + className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-black accent-black focus:ring-gray-400 disabled:cursor-not-allowed" + /> + <p className="text-xs leading-5 text-gray-600"> + <label + htmlFor="open-source-disclosure-consent" + className="cursor-pointer" + > + I consent to disclosing the personal + information above in the public + </label>{" "} + <a + href={WORKFLOWS_REPO_URL} + target="_blank" + rel="noopener noreferrer" + className="font-medium text-gray-900 underline decoration-gray-300 underline-offset-4 transition-colors hover:text-gray-600" + > + Open-Legal-Products/mike-workflows + </a>{" "} + <label + htmlFor="open-source-disclosure-consent" + className="cursor-pointer" + > + GitHub repository and on the + mikeoss.com website. + </label> + </p> + </div> + </div> + )} + </div> + + {error && ( + <p className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700"> + {error} + </p> + )} + </div> + )} + </Modal> + ); +} diff --git a/frontend/src/app/components/workflows/ShareWorkflowModal.tsx b/frontend/src/app/components/workflows/ShareWorkflowModal.tsx deleted file mode 100644 index b2e1459..0000000 --- a/frontend/src/app/components/workflows/ShareWorkflowModal.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { X } from "lucide-react"; -import { - deleteWorkflowShare, - listWorkflowShares, - shareWorkflow, -} from "@/app/lib/mikeApi"; -import { useAuth } from "@/contexts/AuthContext"; -import { EmailPillInput } from "../shared/EmailPillInput"; -import { Modal } from "../shared/Modal"; - -interface Share { - id: string; - shared_with_email: string; - allow_edit: boolean; - created_at: string; -} - -interface Props { - workflowId: string; - workflowName: string; - onClose: () => void; -} - -export function ShareWorkflowModal({ - workflowId, - workflowName, - onClose, -}: Props) { - const [pendingEmails, setPendingEmails] = useState<string[]>([]); - const [allowEdit, setAllowEdit] = useState(false); - const [existingShares, setExistingShares] = useState<Share[]>([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState<string | null>(null); - const { user } = useAuth(); - const ownEmail = user?.email?.trim().toLowerCase() ?? null; - - useEffect(() => { - listWorkflowShares(workflowId) - .then(setExistingShares) - .catch(() => {}) - .finally(() => setLoading(false)); - }, [workflowId]); - - async function handleRemoveShare(shareId: string) { - await deleteWorkflowShare(workflowId, shareId).catch(() => {}); - setExistingShares((prev) => prev.filter((s) => s.id !== shareId)); - } - - async function handleConfirm() { - const emails = ownEmail - ? pendingEmails.filter((email) => email !== ownEmail) - : pendingEmails; - if (emails.length === 0) return; - setSaving(true); - setError(null); - try { - await shareWorkflow(workflowId, { emails, allow_edit: allowEdit }); - const updated = await listWorkflowShares(workflowId); - setExistingShares(updated); - setPendingEmails([]); - } catch (err) { - setError( - err instanceof Error && err.message - ? err.message - : "Unable to share this workflow. Please try again.", - ); - } finally { - setSaving(false); - } - } - - return ( - <Modal - open - onClose={onClose} - breadcrumbs={["Workflows", workflowName, "People"]} - primaryAction={{ - label: saving ? "Sharing…" : "Share", - onClick: handleConfirm, - disabled: saving || pendingEmails.length === 0, - }} - > - <div className="flex min-h-0 flex-1 flex-col gap-6"> - <section className="space-y-3"> - <EmailPillInput - emails={pendingEmails} - onChange={setPendingEmails} - validate={async (email) => - ownEmail && email === ownEmail - ? "You cannot share a workflow with yourself." - : null - } - placeholder="Add people by email…" - autoFocus - /> - - {error ? ( - <div className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700"> - {error} - </div> - ) : null} - </section> - - {/* Permission toggle */} - <section className="flex flex-col gap-3"> - <span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span> - <button - type="button" - onClick={() => setAllowEdit((v) => !v)} - className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${allowEdit ? "bg-gray-900" : "bg-gray-200"}`} - > - <span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} /> - </button> - </section> - - {/* Existing access */} - <section className="min-h-0 flex-1"> - <p className="text-xs font-medium text-gray-700 mb-2">People with access</p> - {loading ? ( - <div className="space-y-2"> - {[1, 2].map((i) => ( - <div key={i} className="flex items-center justify-between"> - <div className="h-3 w-40 rounded bg-gray-100 animate-pulse" /> - <div className="h-3 w-16 rounded bg-gray-100 animate-pulse" /> - </div> - ))} - </div> - ) : existingShares.length === 0 ? ( - <p className="text-sm text-gray-400">None</p> - ) : ( - <div className="space-y-1"> - {existingShares.map((share) => ( - <div key={share.id} className="flex items-center justify-between py-1"> - <span className="text-sm text-gray-700 truncate">{share.shared_with_email}</span> - <div className="flex items-center gap-3 shrink-0"> - <span className="text-xs text-gray-400">{share.allow_edit ? "Can edit" : "Read-only"}</span> - <button - onClick={() => handleRemoveShare(share.id)} - className="text-gray-300 hover:text-red-500 transition-colors" - > - <X className="h-3.5 w-3.5" /> - </button> - </div> - </div> - ))} - </div> - )} - </section> - </div> - </Modal> - ); -} diff --git a/frontend/src/app/components/workflows/UseWorkflowModal.tsx b/frontend/src/app/components/workflows/UseWorkflowModal.tsx new file mode 100644 index 0000000..3a2507b --- /dev/null +++ b/frontend/src/app/components/workflows/UseWorkflowModal.tsx @@ -0,0 +1,392 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Document, Workflow } from "../shared/types"; +import { createTabularReview } from "@/app/lib/mikeApi"; +import { useRouter } from "next/navigation"; +import { useDirectoryData } from "../shared/useDirectoryData"; +import { FileDirectory } from "../shared/FileDirectory"; +import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; +import { Modal } from "../modals/Modal"; +import { ModalFieldLabel } from "../modals/ModalFieldLabel"; +import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle"; +import { ModalSelect } from "../modals/ModalSelect"; +import { ModalTextarea } from "../modals/ModalTextarea"; +import { WorkflowPickerContent } from "./WorkflowPickerContent"; +import { workflowDetailPath } from "./workflowRoutes"; + +interface Props { + workflows: Workflow[]; + workflow: Workflow | null; + onClose: () => void; + skipSelect?: boolean; +} + +function SelectedWorkflowSummary({ workflow }: { workflow: Workflow }) { + return ( + <div className="mb-3 flex shrink-0 items-center justify-between gap-3"> + <span className="shrink-0 text-xs font-medium text-gray-700"> + Selected workflow + </span> + <span className="min-w-0 flex-1 truncate text-right text-xs text-gray-500"> + {workflow.metadata.title} + </span> + </div> + ); +} + +// --------------------------------------------------------------------------- +// UseWorkflowModal +// --------------------------------------------------------------------------- +export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = false }: Props) { + const [screen, setScreen] = useState<"select" | "details" | "documents">("select"); + const [selected, setSelected] = useState<Workflow | null>(workflow); + const [listSearch, setListSearch] = useState(""); + + // Configure screen state + const [inProject, setInProject] = useState(false); + const [selectedProjectId, setSelectedProjectId] = useState<string | null>( + null, + ); + const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>( + new Set(), + ); + const [assistantPrompt, setAssistantPrompt] = useState(""); + const [saving, setSaving] = useState(false); + + const router = useRouter(); + const { saveChat, setNewChatMessages } = useChatHistoryContext(); + const { + loading: dirLoading, + projects, + standaloneDocuments, + } = useDirectoryData(screen !== "select"); + + useEffect(() => { + if (workflow) { + setSelected(workflow); + setScreen(skipSelect ? "details" : "select"); + setListSearch(""); + } else { + setSelected(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workflow?.id]); + + // Reset configure state on back + useEffect(() => { + if (screen === "select") { + resetConfigureState(); + } + }, [screen]); + + function resetConfigureState() { + setInProject(false); + setSelectedProjectId(null); + setSelectedDocIds(new Set()); + setAssistantPrompt(""); + } + + function handleClose() { + setSelected(null); + setScreen("select"); + resetConfigureState(); + onClose(); + } + + if (!workflow) return null; + const wf = selected ?? workflow; + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + async function handleStartChat() { + setSaving(true); + try { + const projectId = inProject ? selectedProjectId! : undefined; + const chatId = await saveChat(projectId); + if (!chatId) return; + const allDocs: Document[] = [ + ...standaloneDocuments, + ...projects.flatMap((p) => p.documents || []), + ]; + const files = allDocs + .filter((d) => selectedDocIds.has(d.id)) + .map((d) => ({ + filename: d.filename, + document_id: d.id, + })); + const content = assistantPrompt.trim() + ? `implement workflow\n${assistantPrompt.trim()}` + : "implement workflow"; + setNewChatMessages([ + { + role: "user", + content, + files: files.length > 0 ? files : undefined, + workflow: { id: wf.id, title: wf.metadata.title }, + }, + ]); + handleClose(); + router.push( + projectId + ? `/projects/${projectId}/assistant/chat/${chatId}` + : `/assistant/chat/${chatId}`, + ); + } finally { + setSaving(false); + } + } + + async function handleCreateReview() { + const allDocs: Document[] = [ + ...standaloneDocuments, + ...projects.flatMap((p) => p.documents || []), + ]; + const docIds = allDocs + .filter((d) => selectedDocIds.has(d.id)) + .map((d) => d.id); + const projectId = inProject ? selectedProjectId! : undefined; + + setSaving(true); + try { + const review = await createTabularReview({ + title: wf.metadata.title, + document_ids: docIds, + columns_config: wf.columns_config || [], + workflow_id: wf.is_system ? undefined : wf.id, + project_id: projectId, + }); + handleClose(); + router.push( + projectId + ? `/projects/${projectId}/tabular-reviews/${review.id}` + : `/tabular-reviews/${review.id}`, + ); + } finally { + setSaving(false); + } + } + + const selectedProject = projects.find((p) => p.id === selectedProjectId); + const projectDocs = selectedProject?.documents ?? []; + const projectOptions = projects.map((project) => ({ + value: project.id, + label: + project.name + + (project.cm_number ? ` (#${project.cm_number})` : ""), + })); + const location = inProject ? "project" : "workspace"; + const locationOptions = + wf.metadata.type === "assistant" + ? [ + { value: "workspace" as const, label: "Assistant" }, + { value: "project" as const, label: "Project assistant" }, + ] + : [ + { value: "workspace" as const, label: "Tabular reviews" }, + { + value: "project" as const, + label: "Project tabular reviews", + }, + ]; + + const breadcrumbs = + screen === "select" + ? ["Workflows", "Select workflow"] + : [ + <button + key="workflows" + type="button" + onClick={() => setScreen("select")} + className="transition-colors hover:text-gray-700" + > + Workflows + </button>, + wf.metadata.title, + wf.metadata.type === "assistant" ? "New Chat" : "New Review", + screen === "details" ? "Details" : "Attach Documents", + ]; + + const selectPageAction = () => { + router.push(workflowDetailPath(wf)); + handleClose(); + }; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + return ( + <Modal + open={!!workflow} + onClose={handleClose} + size={screen === "select" ? "xl" : "lg"} + breadcrumbs={breadcrumbs} + secondaryAction={ + screen === "select" + ? { + label: "View Page", + onClick: selectPageAction, + } + : screen === "details" + ? { + label: "Back", + onClick: () => setScreen("select"), + disabled: saving, + } + : { + label: "Back", + onClick: () => setScreen("details"), + disabled: saving, + } + } + footerStatus={ + screen === "documents" && selectedDocIds.size > 0 ? ( + <span className="text-xs text-gray-400"> + {selectedDocIds.size} selected + </span> + ) : null + } + primaryAction={ + screen === "select" + ? { + label: "Use", + onClick: () => setScreen("details"), + } + : screen === "details" + ? { + label: "Next", + onClick: () => setScreen("documents"), + disabled: + saving || (inProject && !selectedProjectId), + } + : wf.metadata.type === "assistant" + ? { + label: saving ? "Starting…" : "Start Chat", + onClick: handleStartChat, + disabled: + saving || (inProject && !selectedProjectId), + } + : { + label: saving ? "Creating…" : "Create Review", + onClick: handleCreateReview, + disabled: + saving || + selectedDocIds.size === 0 || + (inProject && !selectedProjectId), + } + } + cancelAction={false} + > + {/* ── SELECT SCREEN ── */} + {screen === "select" && ( + <WorkflowPickerContent + workflows={workflows} + selected={wf} + onSelect={(next) => { + if (next) setSelected(next); + }} + search={listSearch} + onSearchChange={setListSearch} + workflowType="all" + previewMode="auto" + showTypeIcon + allowClearPreview={false} + /> + )} + + {/* ── DETAILS SCREEN ── */} + {screen === "details" && ( + <div className="flex min-h-0 flex-1 flex-col"> + <SelectedWorkflowSummary workflow={wf} /> + + <div className="space-y-6"> + <div> + <ModalFieldLabel as="p">Use in</ModalFieldLabel> + <ModalSegmentedToggle + value={location} + onChange={(value) => { + setInProject(value === "project"); + setSelectedProjectId(null); + setSelectedDocIds(new Set()); + }} + options={locationOptions} + /> + </div> + + {inProject && ( + <div> + <ModalFieldLabel htmlFor="workflow-project"> + Project + </ModalFieldLabel> + <ModalSelect + id="workflow-project" + value={selectedProjectId ?? ""} + options={projectOptions} + onChange={(value) => { + setSelectedProjectId(value || null); + setSelectedDocIds(new Set()); + }} + placeholder={ + dirLoading + ? "Loading projects..." + : projects.length + ? "Select project..." + : "No projects found" + } + disabled={dirLoading || projects.length === 0} + /> + </div> + )} + + {wf.metadata.type === "assistant" && ( + <div> + <ModalFieldLabel htmlFor="workflow-additional-message"> + Additional message + </ModalFieldLabel> + <ModalTextarea + id="workflow-additional-message" + value={assistantPrompt} + onChange={(e) => + setAssistantPrompt(e.target.value) + } + placeholder="Add any additional instructions..." + rows={4} + /> + </div> + )} + </div> + </div> + )} + + {/* ── DOCUMENTS SCREEN ── */} + {screen === "documents" && ( + <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> + <div className="flex min-h-0 flex-1 flex-col"> + <FileDirectory + standaloneDocs={ + inProject ? projectDocs : standaloneDocuments + } + directoryProjects={ + inProject ? [] : projects + } + loading={dirLoading} + selectedIds={selectedDocIds} + onChange={setSelectedDocIds} + allowMultiple + forceExpanded={inProject} + emptyMessage={ + inProject + ? "No documents in this project" + : "No documents yet" + } + searchable + searchAutoFocus + showProjectTabs={!inProject} + /> + </div> + </div> + )} + </Modal> + ); +} diff --git a/frontend/src/app/components/workflows/WFColumnViewModal.tsx b/frontend/src/app/components/workflows/WFColumnViewModal.tsx index 31c03a2..392a6b5 100644 --- a/frontend/src/app/components/workflows/WFColumnViewModal.tsx +++ b/frontend/src/app/components/workflows/WFColumnViewModal.tsx @@ -1,10 +1,16 @@ "use client"; +import { createElement } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { ColumnConfig } from "../shared/types"; -import { formatIcon, formatLabel } from "../tabular/columnFormat"; -import { Modal } from "../shared/Modal"; +import { + formatIcon, + formatIconClassName, + formatLabel, +} from "../tabular/columnFormat"; +import { Modal } from "../modals/Modal"; +import { ModalFieldLabel } from "../modals/ModalFieldLabel"; interface Props { col: ColumnConfig; @@ -12,7 +18,9 @@ interface Props { } export function WFColumnViewModal({ col, onClose }: Props) { - const FormatIcon = formatIcon(col.format ?? "text"); + const formatIconElement = createElement(formatIcon(col.format ?? "text"), { + className: `h-3.5 w-3.5 ${formatIconClassName(col.format ?? "text")}`, + }); return ( <Modal open @@ -24,21 +32,21 @@ export function WFColumnViewModal({ col, onClose }: Props) { }} cancelAction={false} > - <div className="flex flex-col gap-4"> + <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"> <div> - <p className="text-sm font-medium text-gray-500 mb-2">Column Title</p> + <ModalFieldLabel as="p" className="text-gray-500">Column Title</ModalFieldLabel> <p className="text-sm text-gray-800">{col.name}</p> </div> <div> - <p className="text-sm font-medium text-gray-500 mb-2">Format</p> + <ModalFieldLabel as="p" className="text-gray-500">Format</ModalFieldLabel> <span className="inline-flex items-center gap-1.5 text-sm text-gray-700"> - <FormatIcon className="h-3.5 w-3.5 text-gray-400" /> + {formatIconElement} {formatLabel(col.format ?? "text")} </span> </div> {col.tags && col.tags.length > 0 && ( <div> - <p className="text-sm font-medium text-gray-500 mb-2.5">Tags</p> + <ModalFieldLabel as="p" className="text-gray-500">Tags</ModalFieldLabel> <div className="flex flex-wrap gap-1.5"> {col.tags.map((tag) => ( <span key={tag} className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{tag}</span> @@ -47,7 +55,7 @@ export function WFColumnViewModal({ col, onClose }: Props) { </div> )} <div> - <p className="text-sm font-medium text-gray-500 mb-2">Prompt</p> + <ModalFieldLabel as="p" className="text-gray-500">Prompt</ModalFieldLabel> <div className="text-base text-gray-700 leading-relaxed font-serif prose prose-base max-w-none"> <ReactMarkdown remarkPlugins={[remarkGfm]}>{col.prompt || "_No prompt defined._"}</ReactMarkdown> </div> diff --git a/frontend/src/app/components/workflows/WFEditColumnModal.tsx b/frontend/src/app/components/workflows/WFEditColumnModal.tsx index b66ab2d..0ec1912 100644 --- a/frontend/src/app/components/workflows/WFEditColumnModal.tsx +++ b/frontend/src/app/components/workflows/WFEditColumnModal.tsx @@ -1,20 +1,17 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { ChevronDown, Plus, X } from "lucide-react"; import type { ColumnConfig, ColumnFormat } from "../shared/types"; import { generateTabularColumnPrompt } from "@/app/lib/mikeApi"; -import { FORMAT_OPTIONS, formatLabel, formatIcon } from "../tabular/columnFormat"; +import { FORMAT_OPTIONS } from "../tabular/columnFormat"; import { TAG_COLORS } from "../tabular/pillUtils"; import { getPresetConfig, PROMPT_PRESETS } from "../tabular/columnPresets"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { Modal } from "../modals/Modal"; +import { ModalFieldLabel } from "../modals/ModalFieldLabel"; +import { ModalSelect } from "../modals/ModalSelect"; +import { ModalTextarea } from "../modals/ModalTextarea"; +import { ModalTextInput } from "../modals/ModalTextInput"; interface ColumnDraft { name: string; @@ -32,6 +29,7 @@ interface Props { } export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) { + const formId = "workflow-edit-column-modal-form"; const [draft, setDraft] = useState<ColumnDraft>({ name: column.name, prompt: column.prompt, @@ -114,34 +112,40 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) }); } - const FormatIcon = formatIcon(draft.format); - - return createPortal( - <div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-6 pt-5 pb-2"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - <span>Workflows</span> - <span>›</span> - <span>Edit column</span> - </div> - <button - onClick={onClose} - className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors" - > - <X className="h-4 w-4" /> - </button> - </div> - - <form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1"> - {/* Body */} - <div className="px-6 pt-3 pb-5 overflow-y-auto flex-1"> + return ( + <Modal + open + onClose={onClose} + breadcrumbs={["Workflows", "Edit column"]} + primaryAction={{ + label: "Save changes", + type: "submit", + form: formId, + disabled: !draft.name.trim() || !draft.prompt.trim(), + }} + cancelAction={{ label: "Cancel", onClick: onClose }} + secondaryAction={{ + label: "Delete", + variant: "danger", + onClick: onDelete, + }} + > + <form + id={formId} + onSubmit={handleSubmit} + className="flex min-h-0 flex-1 flex-col" + > + <div className="min-h-0 flex-1 overflow-y-auto px-1 pb-5 pt-2"> + <ModalFieldLabel htmlFor="workflow-column-name"> + Column title + </ModalFieldLabel> {/* Name row */} <div className="flex items-start gap-2"> <div className="relative flex flex-1 items-start" ref={presetsRef}> - <input + <ModalTextInput + id="workflow-column-name" type="text" + variant="minimal" value={draft.name} onChange={(e) => { const name = e.target.value; @@ -157,14 +161,14 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) }); }} placeholder="Column name" - className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent" + className="flex-1" autoFocus /> <button type="button" onClick={() => setPresetsOpen((v) => !v)} title="Column presets" - className="mt-1.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700" + className="mt-1.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100/70 hover:text-gray-700" > <ChevronDown className={`h-4 w-4 transition-transform ${presetsOpen ? "rotate-180" : ""}`} /> </button> @@ -173,7 +177,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) <button type="button" onClick={() => { update({ name: "", prompt: "", format: "text", tags: [], tagInput: "" }); setPresetsOpen(false); }} - className="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-50 transition-colors border-b border-gray-100" + className="w-full px-3 py-2 text-left text-sm text-gray-400 transition-all hover:bg-gray-100/70 border-b border-gray-100" > No Preset </button> @@ -185,7 +189,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) update({ name: preset.name, prompt: preset.prompt, format: preset.format, tags: preset.tags ?? [], tagInput: "" }); setPresetsOpen(false); }} - className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors" + className="w-full px-3 py-2 text-left text-sm text-gray-700 transition-all hover:bg-gray-100/70" > {preset.name} </button> @@ -197,37 +201,34 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) {/* Format */} <div className="mt-4"> - <label className="text-sm font-medium text-gray-500">Format</label> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none"> - <span className="flex items-center gap-2"> - <FormatIcon className="h-3.5 w-3.5 text-gray-400" /> - {formatLabel(draft.format)} - </span> - <ChevronDown className="h-3.5 w-3.5 text-gray-400" /> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="z-[200]"> - <DropdownMenuRadioGroup - value={draft.format} - onValueChange={(v) => update({ format: v as ColumnFormat, tags: [], tagInput: "" })} - > - {FORMAT_OPTIONS.map((o) => ( - <DropdownMenuRadioItem key={o.value} value={o.value}> - <o.icon className="h-3.5 w-3.5 text-gray-400" /> - {o.label} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> + <ModalFieldLabel htmlFor="workflow-column-format"> + Format + </ModalFieldLabel> + <ModalSelect + id="workflow-column-format" + value={draft.format} + options={FORMAT_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + icon: option.icon, + iconClassName: option.iconClassName, + }))} + onChange={(value) => + update({ + format: value as ColumnFormat, + tags: [], + tagInput: "", + }) + } + /> </div> {/* Tag input */} {draft.format === "tag" && ( <div className="mt-3"> - <label className="text-sm font-medium text-gray-500">Tags</label> + <ModalFieldLabel htmlFor="workflow-column-tag"> + Tags + </ModalFieldLabel> <div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400"> {draft.tags.map((tag, tagIdx) => ( <span @@ -244,14 +245,16 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) </button> </span> ))} - <input + <ModalTextInput + id="workflow-column-tag" type="text" + variant="minimal" value={draft.tagInput} onChange={(e) => update({ tagInput: e.target.value })} onKeyDown={handleTagKeyDown} onBlur={commitTag} placeholder="Add tag…" - className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none" + className="min-w-[80px] flex-1 font-sans text-sm text-gray-700 placeholder:text-gray-400" /> </div> <p className="mt-1 text-xs text-gray-400">Press Enter or comma to add a tag.</p> @@ -260,7 +263,12 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) {/* Prompt */} <div className="mt-4 flex items-center justify-between"> - <label className="text-sm font-medium text-gray-500">Prompt</label> + <ModalFieldLabel + htmlFor="workflow-column-prompt" + className="mb-0" + > + Prompt + </ModalFieldLabel> <button type="button" onClick={autoGeneratePrompt} @@ -275,44 +283,16 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) Auto-Generate Prompt </button> </div> - <textarea + <ModalTextarea + id="workflow-column-prompt" rows={6} value={draft.prompt} onChange={(e) => update({ prompt: e.target.value })} placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…" - className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed" + className="mt-2 min-h-36" /> - </div> - - {/* Footer */} - <div className="flex items-center justify-between border-t border-gray-100 px-6 py-4"> - <button - type="button" - onClick={onDelete} - className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors" - > - Delete - </button> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={onClose} - className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors" - > - Cancel - </button> - <button - type="submit" - disabled={!draft.name.trim() || !draft.prompt.trim()} - className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors" - > - Save changes - </button> - </div> - </div> - </form> - </div> - </div>, - document.body, + </div> + </form> + </Modal> ); } diff --git a/frontend/src/app/components/workflows/WorkflowDetailPage.tsx b/frontend/src/app/components/workflows/WorkflowDetailPage.tsx index b4f1eed..a276f6e 100644 --- a/frontend/src/app/components/workflows/WorkflowDetailPage.tsx +++ b/frontend/src/app/components/workflows/WorkflowDetailPage.tsx @@ -6,27 +6,50 @@ import dynamic from "next/dynamic"; import { Check, ChevronDown, - Info, + Download, + Globe, Pencil, + Play, Plus, Trash2, Users, X, } from "lucide-react"; -import { deleteWorkflow, getWorkflow, updateWorkflow } from "@/app/lib/mikeApi"; -import { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModal"; +import { + deleteWorkflowShare, + deleteWorkflow, + getWorkflow, + listWorkflowShares, + lookupUserByEmail, + shareWorkflow, + updateWorkflow, + type ProjectPeople, +} from "@/app/lib/mikeApi"; +import { UseWorkflowModal } from "@/app/components/workflows/UseWorkflowModal"; import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal"; import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal"; import { AddColumnModal } from "@/app/components/tabular/AddColumnModal"; -import type { ColumnConfig, Workflow } from "@/app/components/shared/types"; -import { BUILT_IN_WORKFLOWS } from "@/app/components/workflows/builtinWorkflows"; -import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat"; -import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup"; -import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu"; +import type { + ColumnConfig, + Workflow, +} from "@/app/components/shared/types"; +import { + formatIcon, + formatIconClassName, + formatLabel, +} from "@/app/components/tabular/columnFormat"; +import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup"; +import { + HeaderActionsMenu, + type HeaderActionsMenuItem, +} from "@/app/components/shared/HeaderActionsMenu"; +import { PeopleModal } from "@/app/components/modals/PeopleModal"; +import { OpenSourceWorkflowModal } from "@/app/components/workflows/OpenSourceWorkflowModal"; import { PageHeader } from "@/app/components/shared/PageHeader"; -import { WorkflowDetailsModal } from "@/app/components/workflows/WorkflowDetailsModal"; -import { useAuth } from "@/contexts/AuthContext"; -import { useUserProfile } from "@/contexts/UserProfileContext"; +import { NewWorkflowModal } from "@/app/components/workflows/NewWorkflowModal"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import { downloadWorkflowZip } from "./workflowZipExport"; // dynamic import keeps Tiptap (browser-only) out of the SSR bundle const WorkflowPromptEditor = dynamic( () => @@ -38,13 +61,17 @@ const WorkflowPromptEditor = dynamic( interface Props { id: string; - workflowType: Workflow["type"]; + workflowType: Workflow["metadata"]["type"]; } type SaveStatus = "idle" | "saving" | "saved"; type DeleteStatus = "idle" | "loading" | "complete"; +type WorkflowShare = Awaited<ReturnType<typeof listWorkflowShares>>[number]; const NAME_COL_W = "w-[332px] shrink-0"; +const CHECKBOX_GUTTER = "h-2.5 w-2.5 shrink-0"; +const WORKFLOW_CONTRIBUTIONS_ENABLED = + process.env.NEXT_PUBLIC_WORKFLOW_CONTRIBUTIONS_ENABLED === "true"; // --------------------------------------------------------------------------- // Page @@ -54,20 +81,19 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { const { user } = useAuth(); const { profile } = useUserProfile(); const stickyCellBg = "bg-[#fafbfc]"; - const builtinWorkflow = - BUILT_IN_WORKFLOWS.find((w) => w.id === id && w.type === workflowType) ?? - null; - const isBuiltin = builtinWorkflow !== null; const [workflow, setWorkflow] = useState<Workflow | null>(null); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); const readOnly = - isBuiltin || (workflow?.is_system ?? false) || workflow?.allow_edit === false; const canShare = !readOnly && (workflow?.is_owner ?? true); + const canOpenSource = + WORKFLOW_CONTRIBUTIONS_ENABLED && + canShare && + workflow?.is_system !== true; // Editor state const [promptMd, setPromptMd] = useState(""); @@ -85,11 +111,14 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { const [editingColumn, setEditingColumn] = useState<ColumnConfig | null>(null); const [viewingColumn, setViewingColumn] = useState<ColumnConfig | null>(null); - // Share popover + // Share / use / details popovers const [shareOpen, setShareOpen] = useState(false); + const [workflowSharedWith, setWorkflowSharedWith] = useState<string[]>([]); const [detailsOpen, setDetailsOpen] = useState(false); + const [useOpen, setUseOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleteStatus, setDeleteStatus] = useState<DeleteStatus>("idle"); + const [openSourceOpen, setOpenSourceOpen] = useState(false); // Column actions dropdown const [colActionsOpen, setColActionsOpen] = useState(false); @@ -109,27 +138,14 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { // Load workflow // --------------------------------------------------------------------------- useEffect(() => { - if (isBuiltin) { - const wf = builtinWorkflow; - if (!wf) { - setNotFound(true); - } else { - setWorkflow(wf); - setPromptMd(wf.prompt_md ?? ""); - setColumns(wf.columns_config ?? []); - } - setLoading(false); - return; - } - getWorkflow(id) .then((wf) => { - if (wf.type !== workflowType) { + if (wf.metadata.type !== workflowType) { setNotFound(true); return; } setWorkflow(wf); - setPromptMd(wf.prompt_md ?? ""); + setPromptMd(wf.skill_md ?? ""); setColumns( (wf.columns_config ?? []) .slice() @@ -138,7 +154,83 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { }) .catch(() => setNotFound(true)) .finally(() => setLoading(false)); - }, [id, isBuiltin, builtinWorkflow, workflowType]); + }, [id, workflowType]); + + const fetchWorkflowShares = useCallback(async () => { + const shares = await listWorkflowShares(id); + setWorkflowSharedWith( + shares.map((share) => share.shared_with_email.trim().toLowerCase()), + ); + return shares; + }, [id]); + + const fetchWorkflowPeople = useCallback(async (): Promise<ProjectPeople> => { + const shares = await fetchWorkflowShares(); + const members = await Promise.all( + shares.map(async (share) => { + const email = share.shared_with_email.trim().toLowerCase(); + const userResult = await lookupUserByEmail(email).catch( + () => null, + ); + return { + email, + display_name: + userResult?.exists === true + ? userResult.display_name + : null, + }; + }), + ); + return { + owner: { + user_id: user?.id ?? workflow?.user_id ?? "", + email: user?.email ?? null, + display_name: profile?.displayName ?? null, + }, + members, + }; + }, [ + fetchWorkflowShares, + profile?.displayName, + user?.email, + user?.id, + workflow?.user_id, + ]); + + async function handleWorkflowSharedWithChange(nextSharedWith: string[]) { + const nextEmails = [ + ...new Set( + nextSharedWith + .map((email) => email.trim().toLowerCase()) + .filter(Boolean), + ), + ]; + const currentShares = await listWorkflowShares(id); + const currentByEmail = new Map<string, WorkflowShare>(); + for (const share of currentShares) { + currentByEmail.set( + share.shared_with_email.trim().toLowerCase(), + share, + ); + } + + const added = nextEmails.filter((email) => !currentByEmail.has(email)); + const removed = currentShares.filter( + (share) => + !nextEmails.includes( + share.shared_with_email.trim().toLowerCase(), + ), + ); + + await Promise.all([ + ...removed.map((share) => deleteWorkflowShare(id, share.id)), + ...(added.length > 0 + ? [shareWorkflow(id, { emails: added, allow_edit: false })] + : []), + ]); + + await fetchWorkflowShares(); + } // --------------------------------------------------------------------------- // Debounced auto-save for prompt @@ -150,7 +242,7 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { setSaveStatus("saving"); debounceRef.current = setTimeout(async () => { try { - await updateWorkflow(id, { prompt_md: newPromptMd }); + await updateWorkflow(id, { skill_md: newPromptMd }); setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2000); } catch { @@ -161,17 +253,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { [id, readOnly], ); - async function handleWorkflowDetailsSave(values: { title: string }) { - if (!workflow || readOnly || !values.title) return; - if (values.title === workflow.title) return; - const updated = await updateWorkflow(id, { title: values.title }); - setWorkflow({ - ...updated, - shared_by_name: - updated.shared_by_name ?? workflow.shared_by_name ?? null, - }); - } - async function handleDeleteWorkflow() { if (!workflow || readOnly || workflow.is_owner === false) return; setDeleteStatus("loading"); @@ -198,7 +279,13 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { setSaveStatus("saving"); try { const updated = await updateWorkflow(id, { columns_config: next }); - setWorkflow(updated); + setWorkflow((current) => ({ + ...updated, + open_source_submission: + updated.open_source_submission ?? + current?.open_source_submission ?? + null, + })); setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2000); } catch { @@ -243,6 +330,7 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { ]} /> <div className="flex min-h-0 flex-1 flex-col"> + <WorkflowMetadataSkeleton /> {workflowType === "tabular" ? ( <TabularWorkflowEditorSkeleton /> ) : ( @@ -261,6 +349,45 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { ); } + const defaultContributorName = + profile?.displayName?.trim() || user?.email || "your account name"; + const openSourcePending = + workflow.open_source_submission?.status === "pending"; + const workflowActionItems: HeaderActionsMenuItem[] = [ + { + label: "Download workflow", + icon: Download, + onSelect: () => downloadWorkflowZip(workflow, promptMd, columns), + }, + ]; + + if (!readOnly) { + workflowActionItems.push({ + label: "Edit details", + icon: Pencil, + onSelect: () => setDetailsOpen(true), + }); + + if (canOpenSource) { + workflowActionItems.push({ + label: "Open source this", + icon: Globe, + onSelect: () => setOpenSourceOpen(true), + }); + } + + workflowActionItems.push({ + label: "Delete", + icon: Trash2, + variant: "danger", + disabled: workflow.is_owner === false, + onSelect: () => { + setDeleteStatus("idle"); + setDeleteOpen(true); + }, + }); + } + return ( <div className="flex flex-col h-full"> {/* Page header */} @@ -275,91 +402,101 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { { label: ( <span className="text-gray-900 truncate max-w-xs"> - {workflow.title} + {workflow.metadata.title} </span> ), }, ]} - actions={[ + actionGroups={[ saveStatus !== "idle" - ? { - type: "custom", - render: ( - <span className="inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm text-gray-500"> - {saveStatus === "saved" ? ( - <Check className="h-3.5 w-3.5 text-green-600" /> - ) : null} - {saveStatus === "saving" - ? "Saving…" - : "Saved"} - </span> - ), - } - : null, - canShare - ? { - onClick: () => setShareOpen(true), - title: "Open workflow people", - iconOnly: true, - icon: <Users className="h-4 w-4" />, - } - : null, - !readOnly - ? { - type: "custom", - render: ( - <HeaderActionsMenu - title="Workflow actions" - items={[ - { - label: "Rename", - icon: Pencil, - onSelect: () => - setDetailsOpen(true), - }, - { - label: "Workflow Details", - icon: Info, - onSelect: () => - setDetailsOpen(true), - }, - { - label: "Delete", - icon: Trash2, - variant: "danger", - disabled: - workflow.is_owner === false, - onSelect: () => { - setDeleteStatus("idle"); - setDeleteOpen(true); - }, - }, - ]} - /> - ), - } - : null, + ? [ + { + type: "custom", + render: ( + <span className="inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm text-gray-500"> + {saveStatus === "saved" ? ( + <Check className="h-3.5 w-3.5 text-green-600" /> + ) : null} + {saveStatus === "saving" + ? "Saving…" + : "Saved"} + </span> + ), + }, + ] + : [], + [ + canShare + ? { + onClick: () => setShareOpen(true), + title: "Open workflow people", + iconOnly: true, + icon: <Users className="h-4 w-4" />, + } + : null, + { + type: "custom", + render: ( + <HeaderActionsMenu + title="Workflow actions" + items={workflowActionItems} + /> + ), + }, + ], + [ + { + label: "Use", + icon: <Play className="h-3.5 w-3.5" />, + onClick: () => setUseOpen(true), + }, + ], ]} /> - <WorkflowDetailsModal + <UseWorkflowModal + workflows={[]} + workflow={useOpen ? workflow : null} + onClose={() => setUseOpen(false)} + skipSelect + /> + <NewWorkflowModal open={detailsOpen} - workflow={workflow} - canEdit={!readOnly} - canShare={canShare} - currentUserDisplayName={profile?.displayName} - currentUserEmail={user?.email} + editWorkflow={workflow} onClose={() => setDetailsOpen(false)} - onSave={handleWorkflowDetailsSave} - onShareWorkflow={() => { + onCreated={() => undefined} + onUpdated={(updated) => { + setWorkflow((current) => + current + ? { + ...current, + ...updated, + shared_by_name: + updated.shared_by_name ?? + current.shared_by_name ?? + null, + open_source_submission: + updated.open_source_submission ?? + current.open_source_submission ?? + null, + } + : updated, + ); setDetailsOpen(false); - setShareOpen(true); }} /> {shareOpen && ( - <ShareWorkflowModal - workflowId={id} - workflowName={workflow.title} + <PeopleModal + open={shareOpen} onClose={() => setShareOpen(false)} + resource={{ id, shared_with: workflowSharedWith }} + fetchPeople={fetchWorkflowPeople} + currentUserEmail={user?.email ?? null} + breadcrumb={[ + "Workflows", + workflow.metadata.title, + "People", + ]} + onSharedWithChange={handleWorkflowSharedWithChange} /> )} <ConfirmPopup @@ -375,12 +512,32 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { setDeleteStatus("idle"); }} /> + <OpenSourceWorkflowModal + open={openSourceOpen} + onClose={() => setOpenSourceOpen(false)} + workflowId={id} + defaultContributorName={defaultContributorName} + pending={openSourcePending} + onSubmitted={(submission) => + setWorkflow((current) => + current + ? { + ...current, + open_source_submission: submission, + } + : current, + ) + } + /> {/* Body */} <div className="flex-1 min-h-0 flex flex-col"> - {workflow.type === "assistant" ? ( + {/* Metadata */} + <WorkflowMetadata workflow={workflow} /> + + {workflow.metadata.type === "assistant" ? ( /* ── Assistant: WYSIWYG editor ── */ - <div className="flex-1 min-h-0 px-4 pb-2 pt-0 md:px-10 md:pb-3"> + <div className="flex-1 min-h-0 px-4 pb-2 pt-4 md:px-10 md:pb-3"> <WorkflowPromptEditor value={promptMd} onChange={readOnly ? undefined : handlePromptChange} @@ -389,17 +546,10 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { </div> ) : ( /* ── Tabular: Column table ── */ - <div className="flex flex-col flex-1 min-h-0"> + <div className="flex flex-col flex-1 min-h-0 pt-2"> {/* Toolbar */} {!readOnly && ( - <div className="flex items-center justify-between px-4 md:px-10 h-10 border-b border-gray-200 shrink-0"> - <button - onClick={() => setAddColumnOpen(true)} - className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors" - > - <Plus className="h-3.5 w-3.5" /> - Add Column - </button> + <div className="flex items-center justify-between h-10 shrink-0 border-b border-gray-200 px-4 md:px-10"> {selectedColIndices.length > 0 && ( <div ref={colActionsRef} className="relative"> <button @@ -429,6 +579,16 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { )} </div> )} + {selectedColIndices.length === 0 && ( + <span aria-hidden="true" /> + )} + <button + onClick={() => setAddColumnOpen(true)} + className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors" + > + <Plus className="h-3.5 w-3.5" /> + Add Column + </button> </div> )} {readOnly && ( @@ -444,13 +604,18 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { {/* Table header */} <div className={`flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none ${readOnly ? "border-t" : ""}`}> <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}> - {columns.length > 0 && ( + {columns.length > 0 ? ( <input type="checkbox" checked={columns.length > 0 && selectedColIndices.length === columns.length} ref={(el) => { if (el) el.indeterminate = selectedColIndices.length > 0 && selectedColIndices.length < columns.length; }} onChange={() => setSelectedColIndices(selectedColIndices.length === columns.length ? [] : columns.map((c) => c.index))} - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" + className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`} + /> + ) : ( + <span + className={CHECKBOX_GUTTER} + aria-hidden="true" /> )} <span>Column Title</span> @@ -488,16 +653,16 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { <div key={col.index} onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)} - className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors" + className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100/70 cursor-pointer transition-colors" > - <div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100`}> + <div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100/70`}> <div className="flex min-w-0 items-center gap-4"> <input type="checkbox" checked={isChecked} onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])} onClick={(e) => e.stopPropagation()} - className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" + className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`} /> <span className="min-w-0 flex-1 truncate text-sm text-gray-800"> {col.name} @@ -506,7 +671,9 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { </div> <div className="ml-auto w-36 shrink-0"> <span className="inline-flex items-center gap-1.5 text-xs text-gray-600"> - <FormatIcon className="h-3.5 w-3.5 text-gray-400" /> + <FormatIcon + className={`h-3.5 w-3.5 ${formatIconClassName(col.format ?? "text")}`} + /> {formatLabel(col.format ?? "text")} </span> </div> @@ -576,9 +743,80 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { ); } +function WorkflowMetadata({ workflow }: { workflow: Workflow }) { + const fields: { label: string; value: string }[] = [ + { label: "Type", value: workflow.metadata.type === "tabular" ? "Tabular" : "Assistant" }, + { label: "Source", value: getWorkflowSourceLabel(workflow) }, + ]; + if (workflow.metadata.language) fields.push({ label: "Language", value: workflow.metadata.language }); + if (workflow.metadata.version) fields.push({ label: "Version", value: workflow.metadata.version }); + if (workflow.metadata.practice) fields.push({ label: "Practice", value: workflow.metadata.practice }); + if (workflow.metadata.jurisdictions?.length) { + fields.push({ label: "Jurisdiction", value: workflow.metadata.jurisdictions.join(", ") }); + } + if (workflow.open_source_submission) { + const statusLabels: Record< + NonNullable<Workflow["open_source_submission"]>["status"], + string + > = { + pending: "Pending review", + approved: "Approved", + rejected: "Rejected", + }; + fields.push({ + label: "Open source", + value: statusLabels[workflow.open_source_submission.status], + }); + } + + return ( + <div className="flex flex-wrap gap-x-8 gap-y-3 px-4 pb-3 pt-1 text-xs shrink-0 md:px-10"> + {fields.map(({ label, value }) => ( + <div key={label} className="flex flex-col gap-0.5"> + <span className="text-gray-400">{label}</span> + <span className="text-gray-700">{value}</span> + </div> + ))} + </div> + ); +} + +function WorkflowMetadataSkeleton() { + const fields = [ + { labelWidth: "w-8", valueWidth: "w-16" }, + { labelWidth: "w-10", valueWidth: "w-14" }, + { labelWidth: "w-12", valueWidth: "w-20" }, + { labelWidth: "w-10", valueWidth: "w-12" }, + { labelWidth: "w-12", valueWidth: "w-24" }, + ]; + + return ( + <div className="flex shrink-0 flex-wrap gap-x-8 gap-y-3 px-4 pb-3 pt-1 md:px-10"> + {fields.map((field, index) => ( + <div key={index} className="flex flex-col gap-0.5"> + <div + className={`h-4 ${field.labelWidth} animate-pulse rounded bg-gray-100`} + /> + <div + className={`h-4 ${field.valueWidth} animate-pulse rounded bg-gray-100`} + /> + </div> + ))} + </div> + ); +} + +function getWorkflowSourceLabel(workflow: Workflow) { + if (workflow.is_system) return "System"; + if (workflow.is_owner === false) { + return workflow.shared_by_name?.trim() || "Shared"; + } + return "User"; +} + function AssistantWorkflowEditorSkeleton() { return ( - <div className="min-h-0 flex-1 px-4 pb-2 pt-0 md:px-10 md:pb-3"> + <div className="min-h-0 flex-1 px-4 pb-2 pt-4 md:px-10 md:pb-3"> <div className="h-full rounded-md border border-gray-200 bg-gray-50 px-5 py-4"> <div className="space-y-3"> <div className="h-3 w-24 animate-pulse rounded bg-gray-100" /> @@ -604,8 +842,8 @@ function AssistantWorkflowEditorSkeleton() { function TabularWorkflowEditorSkeleton() { return ( - <> - <div className="flex h-10 shrink-0 items-center border-b border-gray-200 px-4 md:px-10"> + <div className="flex min-h-0 flex-1 flex-col pt-2"> + <div className="flex h-10 shrink-0 items-center justify-end border-b border-gray-200 px-4 md:px-10"> <div className="h-3 w-20 animate-pulse rounded bg-gray-100" /> </div> @@ -613,7 +851,9 @@ function TabularWorkflowEditorSkeleton() { <div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`} > - <div className="h-2.5 w-2.5 animate-pulse rounded bg-gray-100" /> + <div + className={`${CHECKBOX_GUTTER} animate-pulse rounded bg-gray-100`} + /> <div className="h-2.5 w-20 animate-pulse rounded bg-gray-100" /> </div> <div className="w-36 shrink-0"> @@ -634,7 +874,9 @@ function TabularWorkflowEditorSkeleton() { <div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`} > - <div className="h-2.5 w-2.5 shrink-0 animate-pulse rounded bg-gray-100" /> + <div + className={`${CHECKBOX_GUTTER} animate-pulse rounded bg-gray-100`} + /> <div className="h-3 animate-pulse rounded bg-gray-100" style={{ width: `${40 + (i * 13) % 35}%` }} @@ -653,6 +895,6 @@ function TabularWorkflowEditorSkeleton() { </div> ))} </div> - </> + </div> ); } diff --git a/frontend/src/app/components/workflows/WorkflowDetailsModal.tsx b/frontend/src/app/components/workflows/WorkflowDetailsModal.tsx deleted file mode 100644 index 957afe3..0000000 --- a/frontend/src/app/components/workflows/WorkflowDetailsModal.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import { type ReactNode, useEffect, useMemo, useState } from "react"; -import { Users } from "lucide-react"; -import { Modal } from "@/app/components/shared/Modal"; -import type { Workflow } from "@/app/components/shared/types"; -import { listWorkflowShares } from "@/app/lib/mikeApi"; - -interface WorkflowDetailsModalProps { - open: boolean; - workflow: Workflow | null; - canEdit: boolean; - canShare: boolean; - currentUserDisplayName?: string | null; - currentUserEmail?: string | null; - onClose: () => void; - onSave: (values: { title: string }) => Promise<void>; - onShareWorkflow: () => void; -} - -export function WorkflowDetailsModal({ - open, - workflow, - canEdit, - canShare, - currentUserDisplayName, - currentUserEmail, - onClose, - onSave, - onShareWorkflow, -}: WorkflowDetailsModalProps) { - const [titleDraft, setTitleDraft] = useState(""); - const [shareCount, setShareCount] = useState<number | null>(null); - const [sharesLoading, setSharesLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - if (!open || !workflow) return; - setTitleDraft(workflow.title); - setShareCount(null); - setSaved(false); - setError(null); - }, [open, workflow]); - - useEffect(() => { - if (!open || !workflow || !canShare) { - setSharesLoading(false); - return; - } - - let cancelled = false; - setSharesLoading(true); - listWorkflowShares(workflow.id) - .then((shares) => { - if (!cancelled) setShareCount(shares.length); - }) - .catch(() => { - if (!cancelled) setShareCount(null); - }) - .finally(() => { - if (!cancelled) setSharesLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [canShare, open, workflow]); - - const trimmedTitle = titleDraft.trim(); - const hasChanges = useMemo(() => { - if (!workflow) return false; - return trimmedTitle !== workflow.title; - }, [trimmedTitle, workflow]); - - if (!workflow) return null; - - const typeLabel = workflow.type === "tabular" ? "Tabular" : "Assistant"; - const ownershipLabel = workflow.is_system - ? "Built-in" - : workflow.is_owner === false - ? "Shared with you" - : shareCount && shareCount > 0 - ? "Shared" - : "Private"; - const ownerLabel = - workflow.is_owner === false - ? workflow.shared_by_name?.trim() || "Unknown" - : currentUserDisplayName?.trim() || - currentUserEmail?.trim() || - "You"; - - async function handleSave() { - if (!canEdit || saving || !hasChanges || !trimmedTitle) return; - setSaving(true); - setSaved(false); - setError(null); - try { - await onSave({ title: trimmedTitle }); - setSaved(true); - } catch { - setError("Could not update workflow details."); - } finally { - setSaving(false); - } - } - - return ( - <Modal - open={open} - onClose={onClose} - breadcrumbs={["Workflows", workflow.title, "Details"]} - secondaryAction={ - canShare - ? { - label: "Share Workflow", - icon: <Users className="h-4 w-4" />, - onClick: onShareWorkflow, - } - : undefined - } - footerStatus={ - error ? ( - <span className="text-sm text-red-600">{error}</span> - ) : saved ? ( - <span className="text-sm text-gray-400">Updated</span> - ) : null - } - primaryAction={ - canEdit - ? { - label: saving ? "Updating..." : "Update", - onClick: () => void handleSave(), - disabled: saving || !hasChanges || !trimmedTitle, - } - : undefined - } - cancelAction={canEdit ? undefined : false} - > - <div className="flex flex-col gap-5 py-1"> - <div className="flex flex-col gap-3"> - <label - htmlFor="workflow-details-title" - className="text-xs font-medium text-gray-700" - > - Workflow Name - </label> - <input - id="workflow-details-title" - value={titleDraft} - onChange={(e) => { - setTitleDraft(e.target.value); - setSaved(false); - 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" - /> - </div> - - <div className="divide-y divide-gray-100 text-sm"> - <DetailRow label="Type" value={typeLabel} /> - <DetailRow - label="Ownership" - value={ - sharesLoading ? ( - <span className="inline-block h-4 w-14 rounded bg-gray-100 animate-pulse" /> - ) : ( - ownershipLabel - ) - } - /> - <DetailRow label="Owner" value={ownerLabel} /> - </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> - ); -} diff --git a/frontend/src/app/components/workflows/WorkflowList.tsx b/frontend/src/app/components/workflows/WorkflowList.tsx index 5d94c73..fa7fb27 100644 --- a/frontend/src/app/components/workflows/WorkflowList.tsx +++ b/frontend/src/app/components/workflows/WorkflowList.tsx @@ -17,13 +17,11 @@ import { unhideWorkflow, } from "@/app/lib/mikeApi"; import type { Workflow } from "../shared/types"; -import { BUILT_IN_WORKFLOWS, BUILT_IN_IDS } from "./builtinWorkflows"; -import { DisplayWorkflowModal } from "./DisplayWorkflowModal"; +import { UseWorkflowModal } from "./UseWorkflowModal"; import { NewWorkflowModal } from "./NewWorkflowModal"; import { TableToolbar } from "../shared/TableToolbar"; import { RowActionMenuItems, RowActions } from "../shared/RowActions"; -import { MikeIcon } from "@/components/chat/mike-icon"; -import { useAuth } from "@/contexts/AuthContext"; +import { MikeIcon } from "@/app/components/chat/mike-icon"; import { PageHeader } from "@/app/components/shared/PageHeader"; import { workflowDetailPath } from "./workflowRoutes"; import { @@ -47,28 +45,35 @@ import { TableStickyCell, } from "../shared/TablePrimitive"; -type WorkflowScope = "all" | "builtin" | "custom" | "hidden"; +type WorkflowScope = "all" | "system" | "user" | "shared"; const WORKFLOW_SCOPES: { id: WorkflowScope; label: string }[] = [ { id: "all", label: "All" }, - { id: "builtin", label: "Built-in" }, - { id: "custom", label: "Custom" }, - { id: "hidden", label: "Hidden" }, + { id: "user", label: "User" }, + { id: "shared", label: "Shared with me" }, + { id: "system", label: "System" }, ]; +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters<typeof console.log>) => { + if (isDev) console.log(...args); +}; + export function WorkflowList() { const router = useRouter(); - const { user } = useAuth(); - const [custom, setCustom] = useState<Workflow[]>([]); + const [workflows, setWorkflows] = useState<Workflow[]>([]); const [loading, setLoading] = useState(true); const [selected, setSelected] = useState<Workflow | null>(null); const [activeScope, setActiveScope] = useState<WorkflowScope>("all"); const [newModalOpen, setNewModalOpen] = useState(false); - const [hiddenBuiltinIds, setHiddenBuiltinIds] = useState<string[]>([]); + const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>( + null, + ); + const [hiddenSystemIds, setHiddenSystemIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]); const [actionsOpen, setActionsOpen] = useState(false); const [practiceFilter, setPracticeFilter] = useState<string | null>(null); - const [typeFilter, setTypeFilter] = useState<Workflow["type"] | null>( + const [typeFilter, setTypeFilter] = useState<Workflow["metadata"]["type"] | null>( null, ); const [search, setSearch] = useState(""); @@ -81,18 +86,37 @@ export function WorkflowList() { listHiddenWorkflows(), ]) .then(([assistant, tabular, hidden]) => { - setCustom([...assistant, ...tabular]); - setHiddenBuiltinIds(hidden); + devLog("[workflows/ui:list] loaded", { + assistantCount: assistant.length, + tabularCount: tabular.length, + hiddenCount: hidden.length, + assistantSample: assistant.slice(0, 5).map((workflow) => ({ + id: workflow.id, + title: workflow.metadata.title, + type: workflow.metadata.type, + user_id: workflow.user_id, + is_system: workflow.is_system, + is_owner: workflow.is_owner, + })), + tabularSample: tabular.slice(0, 5).map((workflow) => ({ + id: workflow.id, + title: workflow.metadata.title, + type: workflow.metadata.type, + user_id: workflow.user_id, + is_system: workflow.is_system, + is_owner: workflow.is_owner, + })), + }); + setWorkflows([...assistant, ...tabular]); + setHiddenSystemIds(hidden); + }) + .catch((error) => { + devLog("[workflows/ui:list] failed; showing no workflows", error); + setWorkflows([]); }) - .catch(() => setCustom([])) .finally(() => setLoading(false)); }, []); - useEffect(() => { - setSelectedIds([]); - setActionsOpen(false); - }, [activeScope, practiceFilter, typeFilter]); - useEffect(() => { function handleClick(e: MouseEvent) { if ( @@ -106,31 +130,40 @@ export function WorkflowList() { return () => document.removeEventListener("mousedown", handleClick); }, [actionsOpen]); - const hiddenBuiltins = BUILT_IN_WORKFLOWS.filter((wf) => - hiddenBuiltinIds.includes(wf.id), + const systemWorkflows = workflows.filter((wf) => wf.is_system); + const userWorkflows = workflows.filter( + (wf) => !wf.is_system && wf.is_owner !== false, ); - const visibleBuiltins = BUILT_IN_WORKFLOWS.filter( - (wf) => !hiddenBuiltinIds.includes(wf.id), + const sharedWorkflows = workflows.filter( + (wf) => !wf.is_system && wf.is_owner === false, ); - const all = [...visibleBuiltins, ...custom]; + const hiddenSystem = systemWorkflows.filter((wf) => + hiddenSystemIds.includes(wf.id), + ); + const visibleSystem = systemWorkflows.filter( + (wf) => !hiddenSystemIds.includes(wf.id), + ); + const systemRows = [...visibleSystem, ...hiddenSystem]; + const activeRows = [...userWorkflows, ...sharedWorkflows, ...visibleSystem]; + const allRows = [...userWorkflows, ...sharedWorkflows, ...systemRows]; const byScope = - activeScope === "builtin" - ? visibleBuiltins - : activeScope === "custom" - ? custom - : activeScope === "hidden" - ? hiddenBuiltins - : all; + activeScope === "all" + ? activeRows + : activeScope === "system" + ? systemRows + : activeScope === "user" + ? userWorkflows + : sharedWorkflows; const practices = Array.from( new Set( - byScope.map((wf) => wf.practice).filter((p): p is string => !!p), + byScope.map((wf) => wf.metadata.practice).filter((p): p is string => !!p), ), ).sort(); const q = search.toLowerCase(); const filtered = byScope - .filter((wf) => !practiceFilter || wf.practice === practiceFilter) - .filter((wf) => !typeFilter || wf.type === typeFilter) - .filter((wf) => !q || wf.title.toLowerCase().includes(q)); + .filter((wf) => !practiceFilter || wf.metadata.practice === practiceFilter) + .filter((wf) => !typeFilter || wf.metadata.type === typeFilter) + .filter((wf) => !q || wf.metadata.title.toLowerCase().includes(q)); const allSelected = filtered.length > 0 && @@ -149,17 +182,37 @@ export function WorkflowList() { ); } + function clearSelection() { + setSelectedIds([]); + setActionsOpen(false); + } + + function handleScopeChange(scope: WorkflowScope) { + setActiveScope(scope); + clearSelection(); + } + + function handleTypeFilterChange(value: Workflow["metadata"]["type"] | null) { + setTypeFilter(value); + clearSelection(); + } + + function handlePracticeFilterChange(value: string | null) { + setPracticeFilter(value); + clearSelection(); + } + async function handleHideWorkflow(id: string) { - setHiddenBuiltinIds((prev) => [...prev, id]); + setHiddenSystemIds((prev) => [...prev, id]); await hideWorkflow(id).catch(() => { - setHiddenBuiltinIds((prev) => prev.filter((x) => x !== id)); + setHiddenSystemIds((prev) => prev.filter((x) => x !== id)); }); } async function handleUnhideWorkflow(id: string) { - setHiddenBuiltinIds((prev) => prev.filter((x) => x !== id)); + setHiddenSystemIds((prev) => prev.filter((x) => x !== id)); await unhideWorkflow(id).catch(() => { - setHiddenBuiltinIds((prev) => [...prev, id]); + setHiddenSystemIds((prev) => [...prev, id]); }); } @@ -167,22 +220,26 @@ export function WorkflowList() { const ids = [...selectedIds]; setActionsOpen(false); setSelectedIds([]); - const builtinIds = ids.filter((id) => BUILT_IN_IDS.has(id)); - const customIds = ids.filter((id) => !BUILT_IN_IDS.has(id)); - if (builtinIds.length > 0) { - setHiddenBuiltinIds((prev) => [ + const systemIds = ids.filter( + (id) => workflows.find((workflow) => workflow.id === id)?.is_system, + ); + const customIds = ids.filter((id) => !systemIds.includes(id)); + if (systemIds.length > 0) { + setHiddenSystemIds((prev) => [ ...prev, - ...builtinIds.filter((id) => !prev.includes(id)), + ...systemIds.filter((id) => !prev.includes(id)), ]); await Promise.all( - builtinIds.map((id) => hideWorkflow(id).catch(() => {})), + systemIds.map((id) => hideWorkflow(id).catch(() => {})), ); } if (customIds.length > 0) { await Promise.all( customIds.map((id) => deleteWorkflow(id).catch(() => {})), ); - setCustom((prev) => prev.filter((w) => !customIds.includes(w.id))); + setWorkflows((prev) => + prev.filter((w) => !customIds.includes(w.id)), + ); } } @@ -190,11 +247,11 @@ export function WorkflowList() { const ids = [...selectedIds]; setActionsOpen(false); setSelectedIds([]); - setHiddenBuiltinIds((prev) => prev.filter((id) => !ids.includes(id))); + setHiddenSystemIds((prev) => prev.filter((id) => !ids.includes(id))); await Promise.all(ids.map((id) => unhideWorkflow(id).catch(() => {}))); } - const getTypeMeta = (type: Workflow["type"]) => + const getTypeMeta = (type: Workflow["metadata"]["type"]) => type === "tabular" ? { label: "Tabular", Icon: Table2, className: "text-violet-700" } : { @@ -218,7 +275,7 @@ export function WorkflowList() { className, }; })} - onChange={setTypeFilter} + onChange={handleTypeFilterChange} /> ); @@ -231,10 +288,22 @@ export function WorkflowList() { value: practice, label: practice, }))} - onChange={setPracticeFilter} + onChange={handlePracticeFilterChange} /> ); + const selectedHiddenSystemIds = selectedIds.filter((id) => + hiddenSystemIds.includes(id), + ); + const selectedSystemIds = selectedIds.filter( + (id) => workflows.find((workflow) => workflow.id === id)?.is_system, + ); + const selectedOnlySystem = + selectedIds.length > 0 && selectedIds.length === selectedSystemIds.length; + const selectedOnlyHiddenSystem = + selectedIds.length > 0 && + selectedIds.length === selectedHiddenSystemIds.length; + const toolbarActions = selectedIds.length > 0 ? ( <div ref={actionsRef} className="relative"> @@ -247,19 +316,19 @@ export function WorkflowList() { </button> {actionsOpen && ( <div className={`absolute top-full right-0 mt-1 z-[100] w-36 overflow-hidden ${GLASS_DROPDOWN}`}> - {activeScope === "hidden" ? ( + {selectedOnlyHiddenSystem ? ( <button onClick={handleBulkUnhide} className={`w-full px-3 py-1.5 text-left text-xs text-gray-700 ${GLASS_MENU_ITEM}`} > - Unhide + Activate </button> ) : ( <button onClick={handleBulkRemove} className="w-full px-3 py-1.5 text-left text-xs text-red-600 transition-colors hover:bg-red-500/10" > - Delete + {selectedOnlySystem ? "Deactivate" : "Delete"} </button> )} </div> @@ -295,14 +364,14 @@ export function WorkflowList() { <TableToolbar items={WORKFLOW_SCOPES} active={activeScope} - onChange={setActiveScope} + onChange={handleScopeChange} actions={toolbarActions} /> {/* Table */} - <TableScrollArea> - {/* Column headers */} - <TableHeaderRow> + <TableScrollArea + header={ + <TableHeaderRow> <TableStickyCell header> {loading ? ( <SkeletonDot /> @@ -331,11 +400,15 @@ export function WorkflowList() { {practiceFilterButton} </div> </TableHeaderCell> - <TableHeaderCell className="w-28">Source</TableHeaderCell> + <TableHeaderCell className="w-40">Jurisdiction</TableHeaderCell> + <TableHeaderCell className="w-28">Language</TableHeaderCell> + <TableHeaderCell className="w-44">Source</TableHeaderCell> <TableHeaderCell className="w-8" /> - </TableHeaderRow> + </TableHeaderRow> + } + > - {loading && activeScope !== "builtin" ? ( + {loading ? ( <TableBody> {[1, 2, 3].map((i) => ( <TableRow @@ -353,10 +426,19 @@ export function WorkflowList() { <TableCell className="ml-auto w-28"> <SkeletonLine className="w-16" /> </TableCell> + <TableCell className="w-40"> + <div className="flex items-center gap-1.5"> + <SkeletonDot className="rounded-full" /> + <SkeletonLine className="w-24" /> + </div> + </TableCell> <TableCell className="w-40"> <SkeletonLine className="w-24" /> </TableCell> <TableCell className="w-28"> + <SkeletonLine className="w-16" /> + </TableCell> + <TableCell className="w-44"> <SkeletonLine className="w-14" /> </TableCell> <TableCell className="w-8" /> @@ -365,11 +447,11 @@ export function WorkflowList() { </TableBody> ) : filtered.length === 0 ? ( <TableEmptyState> - {activeScope === "custom" ? ( + {activeScope === "user" ? ( <> <Library className="h-8 w-8 text-gray-300 mb-4" /> <p className="text-2xl font-medium font-serif text-gray-900"> - Custom Workflows + User Workflows </p> <p className="mt-1 text-xs text-gray-400 text-left"> Build reusable prompts and tabular @@ -383,16 +465,15 @@ export function WorkflowList() { + Create New </button> </> - ) : activeScope === "hidden" ? ( + ) : activeScope === "shared" ? ( <> <Library className="h-8 w-8 text-gray-300 mb-4" /> <p className="text-2xl font-medium font-serif text-gray-900"> - Hidden Workflows + Shared Workflows </p> <p className="mt-1 text-xs text-gray-400 text-left"> - Built-in workflows you've hidden will - appear here. You can unhide them at any - time. + Workflows shared with you by other users + will appear here. </p> </> ) : ( @@ -411,15 +492,17 @@ export function WorkflowList() { ) : ( <TableBody> {filtered.map((wf) => { + const isHiddenSystem = hiddenSystemIds.includes(wf.id); const rowBg = selectedIds.includes(wf.id) ? "bg-gray-50" : TABLE_STICKY_CELL_BG; return ( <TableRow key={wf.id} + className={isHiddenSystem ? "opacity-45" : undefined} rightClickDropdown={ wf.is_system - ? activeScope === "hidden" + ? isHiddenSystem ? (close) => ( <RowActionMenuItems onClose={close} @@ -445,11 +528,14 @@ export function WorkflowList() { : (close) => ( <RowActionMenuItems onClose={close} + onEditDetails={() => + setEditingWorkflow(wf) + } onDelete={async () => { await deleteWorkflow( wf.id, ); - setCustom((prev) => + setWorkflows((prev) => prev.filter( (w) => w.id !== @@ -466,26 +552,43 @@ export function WorkflowList() { bgClassName={rowBg} selected={selectedIds.includes(wf.id)} onSelectionChange={() => toggleOne(wf.id)} - label={wf.title} + label={wf.metadata.title} /> <TableCell className="ml-auto w-28"> {(() => { const { label, Icon, className } = - getTypeMeta(wf.type); + getTypeMeta(wf.metadata.type); return ( - <span - className={`inline-flex items-center gap-1.5 text-xs font-medium ${className}`} - > - <Icon className="h-3.5 w-3.5" /> + <span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-700"> + <Icon + className={`h-3.5 w-3.5 ${className}`} + /> {label} </span> ); })()} </TableCell> <TableCell className="w-40"> - {wf.practice ? ( - <span className="text-xs font-medium text-gray-600"> - {wf.practice} + {wf.metadata.practice ? ( + <span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600"> + <span + className={`${GLASS_DOT} ${practiceDotColor( + wf.metadata.practice, + )}`} + /> + {wf.metadata.practice} + </span> + ) : ( + <span className="text-xs text-gray-300"> + — + </span> + )} + </TableCell> + <TableCell className="w-40"> + {wf.metadata.jurisdictions && + wf.metadata.jurisdictions.length > 0 ? ( + <span className="truncate max-w-full text-xs font-medium text-gray-600"> + {wf.metadata.jurisdictions.join(", ")} </span> ) : ( <span className="text-xs text-gray-300"> @@ -494,21 +597,32 @@ export function WorkflowList() { )} </TableCell> <TableCell className="w-28"> + {wf.metadata.language ? ( + <span className="text-xs font-medium text-gray-600"> + {wf.metadata.language} + </span> + ) : ( + <span className="text-xs text-gray-300"> + — + </span> + )} + </TableCell> + <TableCell className="w-44"> {wf.is_system ? ( <span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600"> <MikeIcon size={14} /> - Mike + System </span> - ) : wf.user_id === user?.id ? ( + ) : wf.is_owner !== false ? ( <span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600"> - <User className="h-3.5 w-3.5 text-gray-500" /> - Myself + <User className="h-3.5 w-3.5 text-blue-600" /> + User </span> ) : ( <span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600 truncate max-w-full"> - <User className="h-3.5 w-3.5 text-gray-400 shrink-0" /> + <User className="h-3.5 w-3.5 shrink-0 text-blue-600" /> <span className="truncate"> - {wf.shared_by_name ?? "Shared"} + {getSharedByLabel(wf)} </span> </span> )} @@ -518,7 +632,7 @@ export function WorkflowList() { onClick={(e) => e.stopPropagation()} > {wf.is_system ? ( - activeScope === "hidden" ? ( + isHiddenSystem ? ( <RowActions onUnhide={() => handleUnhideWorkflow(wf.id) @@ -533,9 +647,12 @@ export function WorkflowList() { ) ) : wf.is_owner === false ? null : ( <RowActions + onEditDetails={() => + setEditingWorkflow(wf) + } onDelete={async () => { await deleteWorkflow(wf.id); - setCustom((prev) => + setWorkflows((prev) => prev.filter( (w) => w.id !== wf.id, ), @@ -551,8 +668,8 @@ export function WorkflowList() { )} </TableScrollArea> - <DisplayWorkflowModal - workflows={all} + <UseWorkflowModal + workflows={allRows} workflow={selected} onClose={() => setSelected(null)} /> @@ -561,11 +678,61 @@ export function WorkflowList() { open={newModalOpen} onClose={() => setNewModalOpen(false)} onCreated={(wf) => { - setCustom((prev) => [wf, ...prev]); + setWorkflows((prev) => [wf, ...prev]); setNewModalOpen(false); router.push(workflowDetailPath(wf)); }} /> + + <NewWorkflowModal + open={!!editingWorkflow} + onClose={() => setEditingWorkflow(null)} + onCreated={() => undefined} + editWorkflow={editingWorkflow ?? undefined} + onUpdated={(updated) => { + setWorkflows((prev) => + prev.map((workflow) => + workflow.id === updated.id + ? { ...workflow, ...updated } + : workflow, + ), + ); + setEditingWorkflow(null); + }} + /> </div> ); } + +function getSharedByLabel(workflow: Workflow) { + return workflow.shared_by_name?.trim() || "Shared"; +} + +// Liquid-glass treatment shared by every practice dot: a top inset highlight +// and bottom inset shadow give it depth, plus a slight drop shadow so the bead +// lifts off the row. The color class is appended per practice. +const GLASS_DOT = + "h-2 w-2 shrink-0 rounded-full shadow-[inset_0_1px_0.5px_rgba(255,255,255,0.65),inset_0_-1px_1px_rgba(15,23,42,0.28),0_1px_1.5px_rgba(15,23,42,0.2)]"; + +// Full literal class names so Tailwind's scanner keeps them (no dynamic strings). +const PRACTICE_DOT_COLORS = [ + "bg-blue-500", + "bg-violet-500", + "bg-emerald-500", + "bg-amber-500", + "bg-rose-500", + "bg-cyan-500", + "bg-fuchsia-500", + "bg-lime-500", + "bg-orange-500", + "bg-teal-500", +]; + +/** Deterministic dot color per practice name, so each practice reads consistently. */ +function practiceDotColor(practice: string): string { + let hash = 0; + for (let i = 0; i < practice.length; i++) { + hash = (hash * 31 + practice.charCodeAt(i)) >>> 0; + } + return PRACTICE_DOT_COLORS[hash % PRACTICE_DOT_COLORS.length]; +} diff --git a/frontend/src/app/components/workflows/WorkflowPickerContent.tsx b/frontend/src/app/components/workflows/WorkflowPickerContent.tsx index 3d01211..7bce04f 100644 --- a/frontend/src/app/components/workflows/WorkflowPickerContent.tsx +++ b/frontend/src/app/components/workflows/WorkflowPickerContent.tsx @@ -1,17 +1,16 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { - ChevronDown, - MessageSquare, - Search, - Table2, - X, -} from "lucide-react"; +import { ChevronDown, MessageSquare, Table2, X } from "lucide-react"; +import { SearchBar } from "@/app/components/ui/search-bar"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { ColumnConfig, Workflow } from "../shared/types"; -import { formatIcon, formatLabel } from "../tabular/columnFormat"; +import { + formatIcon, + formatIconClassName, + formatLabel, +} from "../tabular/columnFormat"; import { TAG_COLORS } from "../tabular/pillUtils"; type WorkflowPreviewMode = "auto" | "prompt" | "columns"; @@ -24,7 +23,7 @@ interface WorkflowPickerContentProps { search: string; onSearchChange: (value: string) => void; loading?: boolean; - workflowType?: Workflow["type"] | "all"; + workflowType?: Workflow["metadata"]["type"] | "all"; emptyMessage?: string; previewMode?: WorkflowPreviewMode; disabledWorkflow?: (workflow: Workflow) => boolean; @@ -47,9 +46,23 @@ export function WorkflowPickerContent({ allowClearPreview = true, }: WorkflowPickerContentProps) { const selectedRowRef = useRef<HTMLButtonElement>(null); - const [mobilePane, setMobilePane] = useState<MobilePickerPane>( - selected ? "details" : "list", - ); + const selectedId = selected?.id ?? null; + const [mobilePaneState, setMobilePaneState] = useState<{ + selectedId: string | null; + pane: MobilePickerPane; + }>({ + selectedId, + pane: selected ? "details" : "list", + }); + const mobilePane = + mobilePaneState.selectedId === selectedId + ? mobilePaneState.pane + : selected + ? "details" + : "list"; + const setMobilePane = (pane: MobilePickerPane) => { + setMobilePaneState({ selectedId, pane }); + }; useEffect(() => { if (selectedRowRef.current) { @@ -57,17 +70,13 @@ export function WorkflowPickerContent({ } }, [selected?.id]); - useEffect(() => { - setMobilePane(selected ? "details" : "list"); - }, [selected?.id]); - const normalizedSearch = search.trim().toLowerCase(); const filteredWorkflows = normalizedSearch ? workflows.filter((workflow) => [ - workflow.title, - workflow.practice ?? "", - workflow.is_system ? "Built-in" : "Custom", + workflow.metadata.title, + workflow.metadata.practice ?? "", + workflow.is_system ? "System" : "Custom", ] .join(" ") .toLowerCase() @@ -91,44 +100,26 @@ export function WorkflowPickerContent({ }; return ( - <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden md:flex-row"> + <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-visible md:flex-row"> <div - className={`min-h-0 flex-1 flex-col overflow-hidden ${ - selected ? "md:w-80 md:flex-none md:shrink-0" : "" + className={`min-h-0 min-w-0 flex-1 flex-col overflow-visible ${ + selected ? "md:w-64 md:flex-none md:shrink-0" : "" } ${mobilePane === "details" && selected ? "hidden md:flex" : "flex"}`} > - <div className="shrink-0 pb-2 pt-3"> - <div className="flex h-9 items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3"> - <Search className="h-3.5 w-3.5 shrink-0 text-gray-400" /> - <input - type="text" - placeholder="Search workflows..." - value={search} - onChange={(event) => - onSearchChange(event.target.value) - } - className="flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400" - /> - {search && ( - <button - type="button" - onClick={() => onSearchChange("")} - className="text-gray-400 hover:text-gray-600" - > - <X className="h-3.5 w-3.5" /> - </button> - )} - </div> - </div> + <SearchBar + value={search} + onValueChange={onSearchChange} + placeholder="Search workflows..." + /> - <div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white"> + <div className="min-h-0 min-w-0 flex-1 overflow-y-auto rounded-sm pt-2"> {loading ? ( - <div> + <div className="space-y-px"> {[60, 45, 75, 50, 65, 40, 55].map( (width, index) => ( <div key={index} - className="flex items-center justify-between gap-3 px-3 py-2.5" + className="flex items-center justify-between gap-3 rounded-md px-3 py-2.5" > <div className="h-3 animate-pulse rounded bg-gray-100" @@ -144,13 +135,13 @@ export function WorkflowPickerContent({ {resolvedEmptyMessage} </p> ) : ( - <div> + <div className="space-y-px"> {filteredWorkflows.map((workflow) => { const disabled = disabledWorkflow?.(workflow) ?? false; const isSelected = selected?.id === workflow.id; const TypeIcon = - workflow.type === "tabular" + workflow.metadata.type === "tabular" ? Table2 : MessageSquare; return ( @@ -164,27 +155,27 @@ export function WorkflowPickerContent({ isSelected ? null : workflow, ) } - className={`flex w-full items-center gap-3 px-3 py-2 text-left text-xs transition-colors ${ + className={`flex min-w-0 w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs transition-all ${ isSelected - ? "bg-gray-50 text-gray-900" - : "hover:bg-gray-50" + ? "bg-gray-100 text-gray-900" + : "hover:bg-gray-100/70" } ${disabled ? "cursor-not-allowed opacity-45" : ""}`} > <span - className={`flex-1 truncate ${ + className={`min-w-0 flex-1 truncate ${ isSelected ? "font-medium text-gray-900" : "text-gray-700" }`} > - {workflow.title} + {workflow.metadata.title} </span> {showTypeIcon ? ( <TypeIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" /> ) : ( <span className="shrink-0 text-xs text-gray-400"> {workflow.is_system - ? "Built-in" + ? "System" : "Custom"} </span> )} @@ -226,38 +217,42 @@ function WorkflowPreview({ }) { const resolvedMode = mode === "auto" - ? workflow.type === "tabular" + ? workflow.metadata.type === "tabular" ? "columns" : "prompt" : mode; return ( <div - className={`${className} min-h-0 flex-1 flex-col overflow-hidden pt-3`} + className={`${className} min-h-0 min-w-0 flex-1 flex-col overflow-visible`} > - <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-gray-200 bg-white"> - <div className="flex h-10 shrink-0 items-center justify-between border-b border-gray-200 bg-white px-3"> - <p className="truncate text-sm font-medium text-gray-700"> - {workflow.title} + <div className="flex min-h-0 min-w-0 flex-1 flex-col rounded-2xl border border-white/70 bg-white/55 p-1 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl"> + <div className="flex h-9 shrink-0 items-center justify-between px-3"> + <p className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700"> + {workflow.metadata.title} </p> {allowClear ? ( <button type="button" onClick={onClear} - className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600" + className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100/70 hover:text-gray-600" > <X className="h-3.5 w-3.5" /> </button> ) : null} </div> - {resolvedMode === "columns" ? ( - <WorkflowColumnPreview - columns={workflow.columns_config ?? []} - /> - ) : ( - <WorkflowPromptPreview - content={workflow.prompt_md ?? "_No prompt defined._"} - /> - )} + <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto"> + {resolvedMode === "columns" ? ( + <WorkflowColumnPreview + columns={workflow.columns_config ?? []} + /> + ) : ( + <WorkflowPromptPreview + content={ + workflow.skill_md ?? "_No prompt defined._" + } + /> + )} + </div> </div> </div> ); @@ -267,7 +262,7 @@ function WorkflowPromptPreview({ content }: { content: string }) { const previewContent = stripLeadingMarkdownHeading(content); return ( - <div className="flex-1 overflow-y-auto bg-gray-50 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600"> + <div className="min-w-0 flex-1 break-words rounded-md px-3 py-3 font-serif text-sm leading-relaxed text-gray-600"> <WorkflowPromptMarkdown content={previewContent} /> </div> ); @@ -312,6 +307,34 @@ function WorkflowPromptMarkdown({ content }: { content: string }) { </ol> ), li: ({ children }) => <li>{children}</li>, + table: ({ children }) => ( + <div className="my-3 overflow-x-auto rounded-md border border-gray-200 first:mt-0 last:mb-0"> + <table className="min-w-full border-collapse text-left text-xs"> + {children} + </table> + </div> + ), + thead: ({ children }) => ( + <thead className="bg-gray-50">{children}</thead> + ), + tbody: ({ children }) => ( + <tbody className="divide-y divide-gray-100"> + {children} + </tbody> + ), + tr: ({ children }) => ( + <tr className="divide-x divide-gray-100">{children}</tr> + ), + th: ({ children }) => ( + <th className="px-3 py-2 font-medium text-gray-700"> + {children} + </th> + ), + td: ({ children }) => ( + <td className="px-3 py-2 align-top text-gray-600"> + {children} + </td> + ), strong: ({ children }) => ( <strong className="font-semibold text-gray-800"> {children} @@ -329,7 +352,7 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) { const [expandedIndex, setExpandedIndex] = useState<number | null>(null); const sortedColumns = [...columns].sort((a, b) => a.index - b.index); return ( - <div className="flex-1 overflow-y-auto bg-gray-50"> + <div className="min-w-0 flex-1 space-y-px rounded-sm"> {sortedColumns.length === 0 ? ( <p className="px-4 py-6 text-center text-xs text-gray-400"> No columns defined @@ -339,10 +362,7 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) { const isExpanded = expandedIndex === column.index; const FormatIcon = formatIcon(column.format ?? "text"); return ( - <div - key={column.index} - className="border-b border-gray-200 last:border-b-0" - > + <div key={column.index} className="rounded-md"> <button type="button" onClick={() => @@ -350,13 +370,19 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) { isExpanded ? null : column.index, ) } - className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-xs transition-colors hover:bg-white" + className={`flex min-w-0 w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left text-xs transition-all ${ + isExpanded + ? "bg-gray-100" + : "hover:bg-gray-100/70" + }`} > - <FormatIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" /> - <span className="flex-1 truncate text-gray-800"> + <FormatIcon + className={`h-3.5 w-3.5 shrink-0 ${formatIconClassName(column.format ?? "text")}`} + /> + <span className="min-w-0 flex-1 truncate text-gray-800"> {column.name} </span> - <span className="shrink-0 text-gray-400"> + <span className="max-w-24 shrink-0 truncate text-gray-400"> {formatLabel(column.format ?? "text")} </span> <ChevronDown @@ -364,21 +390,23 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) { /> </button> {isExpanded ? ( - <div className="space-y-3 border-t border-gray-200 bg-white px-4 py-3 font-serif text-sm leading-relaxed text-gray-600"> + <div className="mt-1 min-w-0 space-y-3 break-words rounded-md bg-white/60 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600"> {column.tags && column.tags.length > 0 ? ( <div> <p className="mb-1.5 font-sans text-[11px] font-medium text-gray-600"> Tags </p> <div className="flex flex-wrap gap-1.5"> - {column.tags.map((tag, tagIdx) => ( - <span - key={tag} - className={`inline-block rounded-full px-1.5 py-0.5 font-sans text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`} - > - {tag} - </span> - ))} + {column.tags.map( + (tag, tagIdx) => ( + <span + key={tag} + className={`inline-block rounded-full px-1.5 py-0.5 font-sans text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`} + > + {tag} + </span> + ), + )} </div> </div> ) : null} diff --git a/frontend/src/app/components/workflows/WorkflowPickerModal.tsx b/frontend/src/app/components/workflows/WorkflowPickerModal.tsx index 327dd42..0143a3c 100644 --- a/frontend/src/app/components/workflows/WorkflowPickerModal.tsx +++ b/frontend/src/app/components/workflows/WorkflowPickerModal.tsx @@ -2,16 +2,20 @@ import { useEffect, useState, type ReactNode } from "react"; import { listWorkflows } from "@/app/lib/mikeApi"; -import { Modal } from "../shared/Modal"; +import { Modal } from "../modals/Modal"; import type { Workflow } from "../shared/types"; -import { BUILT_IN_WORKFLOWS } from "./builtinWorkflows"; import { WorkflowPickerContent } from "./WorkflowPickerContent"; +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters<typeof console.log>) => { + if (isDev) console.log(...args); +}; + interface WorkflowPickerModalProps { open: boolean; onClose: () => void; onSelect: (workflow: Workflow) => Promise<void> | void; - workflowType: Workflow["type"]; + workflowType: Workflow["metadata"]["type"]; breadcrumbs: ReactNode[]; primaryLabel?: string; selectingLabel?: string; @@ -42,44 +46,49 @@ export function WorkflowPickerModal({ useEffect(() => { if (!open) return; let cancelled = false; - const builtins = BUILT_IN_WORKFLOWS.filter( - (workflow) => workflow.type === workflowType, - ); const frame = requestAnimationFrame(() => { if (cancelled) return; - setWorkflows(builtins); + setWorkflows([]); setLoading(true); - setSelected( - initialWorkflowId - ? builtins.find((workflow) => workflow.id === initialWorkflowId) ?? - null - : null, - ); + setSelected(null); setSearch(""); }); listWorkflows(workflowType) - .then((custom) => { + .then((workflows) => { if (cancelled) return; - const all = [...builtins, ...custom]; - setWorkflows(all); + devLog("[workflows/ui:picker] loaded", { + workflowType, + workflowCount: workflows.length, + systemCount: workflows.filter((workflow) => workflow.is_system) + .length, + sample: workflows.slice(0, 5).map((workflow) => ({ + id: workflow.id, + title: workflow.metadata.title, + type: workflow.metadata.type, + user_id: workflow.user_id, + is_system: workflow.is_system, + is_owner: workflow.is_owner, + })), + }); + setWorkflows(workflows); if (initialWorkflowId) { setSelected( - all.find((workflow) => workflow.id === initialWorkflowId) ?? - null, - ); - } - }) - .catch(() => { - if (cancelled) return; - if (initialWorkflowId) { - setSelected( - builtins.find( + workflows.find( (workflow) => workflow.id === initialWorkflowId, ) ?? null, ); } }) + .catch((error) => { + if (cancelled) return; + devLog("[workflows/ui:picker] failed", { + workflowType, + error, + }); + setWorkflows([]); + setSelected(null); + }) .finally(() => { if (!cancelled) setLoading(false); }); diff --git a/frontend/src/app/components/workflows/WorkflowPromptEditor.tsx b/frontend/src/app/components/workflows/WorkflowPromptEditor.tsx index 4ea6f09..1b33ff8 100644 --- a/frontend/src/app/components/workflows/WorkflowPromptEditor.tsx +++ b/frontend/src/app/components/workflows/WorkflowPromptEditor.tsx @@ -1,18 +1,22 @@ "use client"; -import { useEditor, EditorContent } from "@tiptap/react"; +import { useEditor, EditorContent, useEditorState } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; +import { TableKit } from "@tiptap/extension-table"; import { Markdown } from "tiptap-markdown"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Bold, + Code2, Heading1, Heading2, Heading3, Italic, List, ListOrdered, + Table2, } from "lucide-react"; +import { Button } from "@/app/components/ui/button"; interface Props { value: string; @@ -20,42 +24,71 @@ interface Props { readOnly?: boolean; } -function ToolbarBtn({ +const TABLE_PICKER_MAX_ROWS = 8; +const TABLE_PICKER_MAX_COLS = 8; +const INACTIVE_FORMATTING = { + heading1: false, + heading2: false, + heading3: false, + bold: false, + italic: false, + bulletList: false, + orderedList: false, +}; + +function AppToolbarButton({ onClick, active, title, children, }: { onClick: () => void; - active: boolean; + active?: boolean; title: string; children: React.ReactNode; }) { return ( - <button + <Button type="button" + variant="ghost" + size="icon-sm" title={title} + aria-label={title} + aria-pressed={active} + className={`h-7 w-7 text-gray-600 hover:bg-white hover:text-gray-900 ${ + active ? "bg-gray-300 text-gray-950 hover:bg-gray-300" : "" + }`} onMouseDown={(e) => { e.preventDefault(); // keep editor focus onClick(); }} - className={`p-1.5 rounded transition-colors ${ - active - ? "bg-gray-200 text-gray-900" - : "text-gray-400 hover:bg-gray-100 hover:text-gray-600" - }`} > {children} - </button> + </Button> ); } +function getEditorMarkdown(editor: NonNullable<ReturnType<typeof useEditor>>) { + // tiptap-markdown adds .markdown to storage but isn't typed on Editor.storage + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (editor.storage as any).markdown.getMarkdown() as string; +} + export function WorkflowPromptEditor({ value, onChange, readOnly = false, }: Props) { const lastEmittedRef = useRef(value); + const rawTextareaRef = useRef<HTMLTextAreaElement>(null); + const tablePickerRef = useRef<HTMLDivElement>(null); + const [rawMode, setRawMode] = useState(false); + const [rawMarkdown, setRawMarkdown] = useState(value); + const [tablePickerOpen, setTablePickerOpen] = useState(false); + const [tablePickerSize, setTablePickerSize] = useState<{ + rows: number; + cols: number; + } | null>(null); const editor = useEditor({ extensions: [ @@ -66,6 +99,11 @@ export function WorkflowPromptEditor({ blockquote: false, horizontalRule: false, }), + TableKit.configure({ + table: { + renderWrapper: true, + }, + }), Markdown.configure({ html: false, transformPastedText: true, @@ -76,19 +114,31 @@ export function WorkflowPromptEditor({ editable: !readOnly, immediatelyRender: false, onUpdate: ({ editor }) => { - // tiptap-markdown adds .markdown to storage but isn't typed on Editor.storage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const md: string = (editor.storage as any).markdown.getMarkdown(); + const md = getEditorMarkdown(editor); lastEmittedRef.current = md; + setRawMarkdown(md); onChange?.(md); }, editorProps: { attributes: { - class: "workflow-editor-content", + class: "tiptap workflow-editor-content", }, }, }); + const activeFormatting = useEditorState({ + editor, + selector: ({ editor }) => ({ + heading1: editor?.isActive("heading", { level: 1 }) ?? false, + heading2: editor?.isActive("heading", { level: 2 }) ?? false, + heading3: editor?.isActive("heading", { level: 3 }) ?? false, + bold: editor?.isActive("bold") ?? false, + italic: editor?.isActive("italic") ?? false, + bulletList: editor?.isActive("bulletList") ?? false, + orderedList: editor?.isActive("orderedList") ?? false, + }), + }) ?? INACTIVE_FORMATTING; + // Sync external value (e.g. on load from API) useEffect(() => { if (!editor || editor.isDestroyed) return; @@ -98,6 +148,208 @@ export function WorkflowPromptEditor({ } }, [value, editor]); + useEffect(() => { + if (!tablePickerOpen) return; + + function handlePointerDown(event: MouseEvent) { + if ( + tablePickerRef.current && + !tablePickerRef.current.contains(event.target as Node) + ) { + setTablePickerOpen(false); + setTablePickerSize(null); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setTablePickerOpen(false); + setTablePickerSize(null); + } + } + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [tablePickerOpen]); + + function handleRawToggle() { + if (!editor || editor.isDestroyed) return; + if (rawMode) { + lastEmittedRef.current = rawMarkdown; + editor.commands.setContent(rawMarkdown); + onChange?.(rawMarkdown); + setRawMode(false); + return; + } + setRawMarkdown(getEditorMarkdown(editor)); + setRawMode(true); + } + + function handleRawChange(next: string) { + setRawMarkdown(next); + lastEmittedRef.current = next; + onChange?.(next); + } + + function updateRawMarkdown( + next: string, + selectionStart: number, + selectionEnd: number, + ) { + setRawMarkdown(next); + lastEmittedRef.current = next; + onChange?.(next); + window.requestAnimationFrame(() => { + rawTextareaRef.current?.focus(); + rawTextareaRef.current?.setSelectionRange( + selectionStart, + selectionEnd, + ); + }); + } + + function transformRawSelection( + transform: ( + selected: string, + start: number, + end: number, + ) => { + replacement: string; + selectionStart: number; + selectionEnd: number; + }, + ) { + const textarea = rawTextareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selected = rawMarkdown.slice(start, end); + const result = transform(selected, start, end); + const next = + rawMarkdown.slice(0, start) + + result.replacement + + rawMarkdown.slice(end); + updateRawMarkdown(next, result.selectionStart, result.selectionEnd); + } + + function applyRawInline(marker: "*" | "**") { + transformRawSelection((selected, start) => { + const replacement = `${marker}${selected}${marker}`; + const innerStart = start + marker.length; + const innerEnd = innerStart + selected.length; + return { + replacement, + selectionStart: selected ? innerStart : innerStart, + selectionEnd: selected ? innerEnd : innerStart, + }; + }); + } + + function transformRawLines( + transformLine: (line: string, index: number) => string, + ) { + const textarea = rawTextareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const lineStart = rawMarkdown.lastIndexOf("\n", Math.max(0, start - 1)) + 1; + const lineEnd = + end > start && rawMarkdown[end - 1] === "\n" + ? end - 1 + : rawMarkdown.indexOf("\n", end); + const resolvedLineEnd = + lineEnd === -1 ? rawMarkdown.length : lineEnd; + const block = rawMarkdown.slice(lineStart, resolvedLineEnd); + let lineIndex = 0; + const replacement = block + .split("\n") + .map((line) => { + if (!line.trim()) return line; + const next = transformLine(line, lineIndex); + lineIndex += 1; + return next; + }) + .join("\n"); + const next = + rawMarkdown.slice(0, lineStart) + + replacement + + rawMarkdown.slice(resolvedLineEnd); + updateRawMarkdown( + next, + lineStart, + lineStart + replacement.length, + ); + } + + function applyRawHeading(level: 1 | 2 | 3) { + const prefix = `${"#".repeat(level)} `; + transformRawLines((line) => prefix + line.replace(/^#{1,6}\s+/, "")); + } + + function applyRawBulletList() { + transformRawLines((line) => line.replace(/^(\s*)(?:[-*+]|\d+\.)\s+/, "$1").replace(/^(\s*)/, "$1- ")); + } + + function applyRawOrderedList() { + transformRawLines((line, index) => + line + .replace(/^(\s*)(?:[-*+]|\d+\.)\s+/, "$1") + .replace(/^(\s*)/, `$1${index + 1}. `), + ); + } + + function insertRawTable(rows: number, cols: number) { + const textarea = rawTextareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const before = rawMarkdown.slice(0, start); + const after = rawMarkdown.slice(textarea.selectionEnd); + // Keep the table on its own line(s), whatever the caret sits on. + const lead = before.length === 0 || before.endsWith("\n") ? "" : "\n"; + const trail = after.length === 0 || after.startsWith("\n") ? "" : "\n"; + const header = + "| " + + Array.from({ length: cols }, (_, index) => `Column ${index + 1}`).join( + " | ", + ) + + " |"; + const separator = + "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |"; + const body = Array.from( + { length: Math.max(0, rows - 1) }, + () => "| " + Array.from({ length: cols }, () => " ").join(" | ") + " |", + ); + const table = [header, separator, ...body].join("\n") + "\n"; + const replacement = `${lead}${table}${trail}`; + const caret = before.length + replacement.length; + updateRawMarkdown(before + replacement + after, caret, caret); + } + + function insertTable(rows: number, cols: number) { + setTablePickerOpen(false); + setTablePickerSize(null); + + if (rawMode) { + insertRawTable(rows, cols); + return; + } + + editor + ?.chain() + .focus() + .insertTable({ + rows, + cols, + withHeaderRow: true, + }) + .run(); + } + return ( <div className={`flex h-full flex-col overflow-hidden bg-white ${ @@ -108,90 +360,221 @@ export function WorkflowPromptEditor({ > {!readOnly && editor && ( <div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-gray-100 bg-gray-50 shrink-0"> - <ToolbarBtn + <AppToolbarButton onClick={() => - editor - .chain() - .focus() - .toggleHeading({ level: 1 }) - .run() + rawMode + ? applyRawHeading(1) + : editor + .chain() + .focus() + .toggleHeading({ level: 1 }) + .run() } - active={editor.isActive("heading", { level: 1 })} + active={!rawMode && activeFormatting.heading1} title="Heading 1" > <Heading1 className="h-4 w-4" /> - </ToolbarBtn> - <ToolbarBtn + </AppToolbarButton> + <AppToolbarButton onClick={() => - editor - .chain() - .focus() - .toggleHeading({ level: 2 }) - .run() + rawMode + ? applyRawHeading(2) + : editor + .chain() + .focus() + .toggleHeading({ level: 2 }) + .run() } - active={editor.isActive("heading", { level: 2 })} + active={!rawMode && activeFormatting.heading2} title="Heading 2" > <Heading2 className="h-4 w-4" /> - </ToolbarBtn> - <ToolbarBtn + </AppToolbarButton> + <AppToolbarButton onClick={() => - editor - .chain() - .focus() - .toggleHeading({ level: 3 }) - .run() + rawMode + ? applyRawHeading(3) + : editor + .chain() + .focus() + .toggleHeading({ level: 3 }) + .run() } - active={editor.isActive("heading", { level: 3 })} + active={!rawMode && activeFormatting.heading3} title="Heading 3" > <Heading3 className="h-4 w-4" /> - </ToolbarBtn> + </AppToolbarButton> <div className="w-px h-4 bg-gray-200 mx-1 shrink-0" /> - <ToolbarBtn + <AppToolbarButton onClick={() => - editor.chain().focus().toggleBold().run() + rawMode + ? applyRawInline("**") + : editor.chain().focus().toggleBold().run() } - active={editor.isActive("bold")} + active={!rawMode && activeFormatting.bold} title="Bold" > <Bold className="h-4 w-4" /> - </ToolbarBtn> - <ToolbarBtn + </AppToolbarButton> + <AppToolbarButton onClick={() => - editor.chain().focus().toggleItalic().run() + rawMode + ? applyRawInline("*") + : editor.chain().focus().toggleItalic().run() } - active={editor.isActive("italic")} + active={!rawMode && activeFormatting.italic} title="Italic" > <Italic className="h-4 w-4" /> - </ToolbarBtn> + </AppToolbarButton> <div className="w-px h-4 bg-gray-200 mx-1 shrink-0" /> - <ToolbarBtn + <AppToolbarButton onClick={() => - editor.chain().focus().toggleBulletList().run() + rawMode + ? applyRawBulletList() + : editor.chain().focus().toggleBulletList().run() } - active={editor.isActive("bulletList")} + active={!rawMode && activeFormatting.bulletList} title="Bullet list" > <List className="h-4 w-4" /> - </ToolbarBtn> - <ToolbarBtn + </AppToolbarButton> + <AppToolbarButton onClick={() => - editor.chain().focus().toggleOrderedList().run() + rawMode + ? applyRawOrderedList() + : editor + .chain() + .focus() + .toggleOrderedList() + .run() } - active={editor.isActive("orderedList")} + active={!rawMode && activeFormatting.orderedList} title="Numbered list" > <ListOrdered className="h-4 w-4" /> - </ToolbarBtn> + </AppToolbarButton> + <div className="w-px h-4 bg-gray-200 mx-1 shrink-0" /> + <div ref={tablePickerRef} className="relative"> + <AppToolbarButton + onClick={() => + setTablePickerOpen((open) => { + const nextOpen = !open; + if (!nextOpen) { + setTablePickerSize(null); + } + return nextOpen; + }) + } + active={tablePickerOpen} + title="Insert table" + > + <Table2 className="h-4 w-4" /> + </AppToolbarButton> + {tablePickerOpen && ( + <div + role="dialog" + aria-label="Insert table" + className="absolute left-0 top-full z-[250] mt-1 w-max rounded-md border border-gray-200 bg-white p-2 shadow-lg" + > + <div + className="grid gap-1" + style={{ + gridTemplateColumns: `repeat(${TABLE_PICKER_MAX_COLS}, 1rem)`, + }} + > + {Array.from( + { length: TABLE_PICKER_MAX_ROWS }, + (_, rowIndex) => + Array.from( + { + length: TABLE_PICKER_MAX_COLS, + }, + (_, colIndex) => { + const rows = rowIndex + 1; + const cols = colIndex + 1; + const selected = + tablePickerSize !== + null && + rows <= + tablePickerSize.rows && + cols <= + tablePickerSize.cols; + + return ( + <button + key={`${rows}-${cols}`} + type="button" + aria-label={`Insert ${rows} by ${cols} table`} + onMouseEnter={() => + setTablePickerSize({ + rows, + cols, + }) + } + onFocus={() => + setTablePickerSize({ + rows, + cols, + }) + } + onMouseDown={( + event, + ) => { + event.preventDefault(); + insertTable( + rows, + cols, + ); + }} + className={`h-4 w-4 rounded-[3px] border transition-colors ${ + selected + ? "border-gray-700 bg-gray-800" + : "border-gray-200 bg-white hover:border-gray-400" + }`} + /> + ); + }, + ), + )} + </div> + <div className="mt-2 text-center text-[11px] font-medium text-gray-500"> + {tablePickerSize + ? `${tablePickerSize.rows} x ${tablePickerSize.cols}` + : "Select table size"} + </div> + </div> + )} + </div> + <div className="ml-auto" /> + <AppToolbarButton + onClick={handleRawToggle} + active={rawMode} + title={rawMode ? "Show rich editor" : "Show raw Markdown"} + > + <Code2 className="h-4 w-4" /> + </AppToolbarButton> </div> )} {readOnly && ( - <div className="flex h-9 shrink-0 items-center bg-gray-50 px-5"> + <div className="flex h-9 shrink-0 items-center justify-between bg-gray-50 px-5"> <span className="text-xs font-medium text-gray-500"> Read-only </span> + {editor && ( + <AppToolbarButton + onClick={handleRawToggle} + active={rawMode} + title={ + rawMode + ? "Show rich editor" + : "Show raw Markdown" + } + > + <Code2 className="h-4 w-4" /> + </AppToolbarButton> + )} </div> )} <div @@ -199,7 +582,21 @@ export function WorkflowPromptEditor({ readOnly ? "border-t border-gray-100" : "" }`} > - <EditorContent editor={editor} /> + {rawMode ? ( + <textarea + ref={rawTextareaRef} + value={rawMarkdown} + onChange={(event) => + handleRawChange(event.target.value) + } + readOnly={readOnly} + spellCheck={false} + className="h-full min-h-full w-full resize-none bg-white px-5 py-4 font-mono text-xs leading-6 text-gray-800 outline-none placeholder:text-gray-400 read-only:cursor-default" + aria-label="Raw Markdown" + /> + ) : ( + <EditorContent editor={editor} /> + )} </div> </div> ); diff --git a/frontend/src/app/components/workflows/builtinWorkflows.ts b/frontend/src/app/components/workflows/builtinWorkflows.ts deleted file mode 100644 index 9abe217..0000000 --- a/frontend/src/app/components/workflows/builtinWorkflows.ts +++ /dev/null @@ -1,1246 +0,0 @@ -import type { Workflow } from "../shared/types"; - -export const BUILT_IN_WORKFLOWS: Workflow[] = [ - { - id: "builtin-cp-checklist", - user_id: null, - is_system: true, - created_at: "", - title: "Generate CP Checklist", - type: "assistant", - practice: "General Transactions", - 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.", - columns_config: null, - }, - { - id: "builtin-coc-dd", - user_id: null, - is_system: true, - created_at: "", - title: "Change of Control Review", - type: "tabular", - practice: "Corporate", - prompt_md: - "## Change of Control Due Diligence Review\n\n" + - "This workflow performs a change of control due diligence review across the selected documents.", - columns_config: [ - { - index: 0, - name: "Parties", - format: "bulleted_list", - prompt: "Identify all parties to this agreement. For each party state their full legal name and their role (e.g. counterparty, licensor, lender, supplier).", - }, - { - index: 1, - name: "Date", - format: "date", - prompt: "What is the date of this agreement? If a commencement date differs from the signing date, state both.", - }, - { - index: 2, - name: "Term", - format: "text", - prompt: "What is the term or duration of this agreement? State the start and end dates or the length of the term.", - }, - { - index: 3, - name: "Change of Control Clause", - prompt: "Identify and summarize the change of control clause(s) in this document. Quote the exact triggering language and specify what constitutes a 'change of control'.", - }, - { - index: 4, - name: "Consent Required", - prompt: "Does a change of control require prior consent from any party? Identify who must consent, the notice period, and any conditions.", - }, - { - index: 5, - name: "Termination Rights", - prompt: "What termination rights arise upon a change of control? Who can terminate, and what are the notice requirements?", - }, - { - index: 6, - name: "Put/Call Options", - prompt: "Are there any put or call options triggered by a change of control? Summarize the terms, pricing, and exercise period.", - }, - { - index: 7, - name: "Financial Implications", - prompt: "What are the financial implications of a change of control? Include any fees, payments, accelerated obligations, or pricing adjustments.", - }, - ], - }, - { - id: "builtin-credit-summary", - user_id: null, - is_system: true, - created_at: "", - title: "Credit Agreement Summary", - type: "assistant", - practice: "Finance", - 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.", - columns_config: null, - }, - - // ─── Commercial Agreement ─────────────────────────────────────────────────── - { - id: "builtin-commercial-agreement", - user_id: null, - is_system: true, - created_at: "", - title: "Commercial Agreement Review", - type: "tabular", - practice: "General Transactions", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Parties", - format: "bulleted_list", - prompt: "Identify all parties to this agreement. For each party state their full legal name, jurisdiction of incorporation (if stated), and their role in the agreement (e.g. supplier, customer, licensor).", - }, - { - index: 1, - name: "Scope of Work", - format: "text", - prompt: "Summarise the scope of work or services to be provided under this agreement. What are the key deliverables, obligations, or services? Identify any limitations or exclusions to the scope.", - }, - { - index: 2, - name: "Amends Earlier Agreement", - format: "yes_no", - prompt: "Does this agreement amend, restate, supplement, or replace an earlier agreement? If yes, identify the earlier agreement by name and date.", - }, - { - index: 3, - name: "Effective Date", - format: "date", - prompt: "What is the effective date or commencement date of this agreement? If no explicit date is stated, note when it is deemed to take effect.", - }, - { - index: 4, - name: "Term", - format: "text", - prompt: "What is the duration or term of this agreement? State the initial term length and any conditions that affect the duration.", - }, - { - index: 5, - name: "Renewal", - format: "text", - prompt: "What renewal provisions apply? Specify whether renewal is automatic or requires notice, the renewal period, and any conditions or notice periods required to prevent automatic renewal.", - }, - { - index: 6, - name: "Pricing", - format: "text", - prompt: "What is the pricing structure under this agreement? Identify all fees, rates, charges, and payment terms including currency, payment schedule, and invoicing requirements.", - }, - { - index: 7, - name: "Price Adjustments", - format: "text", - prompt: "Are there any price adjustment mechanisms in this agreement? Identify any indexation, CPI/RPI linkage, benchmarking, volume-based adjustments, or other mechanisms that allow prices to change over the term.", - }, - { - index: 8, - name: "Penalties for Late Payment", - format: "text", - prompt: "What penalties or consequences apply for late payment? Include any interest rates on overdue amounts, suspension rights, or other remedies available to the payee.", - }, - { - index: 9, - name: "Estimated Contract Value", - format: "monetary_amount", - prompt: "What is the total estimated or stated contract value? If no single figure is given, calculate or estimate based on stated rates and term. State the currency and any assumptions made.", - }, - { - index: 10, - name: "Limitation of Liability", - format: "text", - prompt: "What limitations of liability apply? Identify any caps on liability (including how they are calculated), exclusions of consequential or indirect loss, and any carve-outs from the cap (e.g. fraud, death, IP infringement).", - }, - { - index: 11, - name: "IP Ownership and Licensing", - format: "text", - prompt: "How is intellectual property ownership and licensing addressed? Identify who owns pre-existing IP, who owns newly created IP, and what licences are granted to each party. Note any restrictions on use.", - }, - { - index: 12, - name: "Change of Control", - format: "text", - prompt: "Is there a change of control provision? If so, describe what constitutes a change of control, whether consent is required, and what rights (e.g. termination, assignment) are triggered.", - }, - { - index: 13, - name: "Force Majeure", - format: "text", - prompt: "Summarise the force majeure clause. What events qualify, what obligations are suspended, how long must the event persist before termination is permitted, and what notice is required?", - }, - { - index: 14, - name: "Termination Rights", - format: "text", - prompt: "What are the termination rights of each party? Identify termination for convenience (including notice period), termination for cause (including cure periods), and the consequences of termination (e.g. payment obligations, survival of terms).", - }, - { - index: 15, - name: "Liquidated Damages", - format: "text", - prompt: "Are there any liquidated damages provisions? If so, identify what triggers them, the applicable rate or formula, any cap on aggregate liquidated damages, and whether they are the exclusive remedy.", - }, - { - index: 16, - name: "Governing Law", - format: "text", - prompt: "What governing law applies to this agreement? State the jurisdiction and any specific legal system referenced.", - }, - { - index: 17, - name: "Dispute Resolution", - format: "text", - prompt: "How are disputes resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen forum or seat, any escalation or mediation steps required before formal proceedings, and the language of proceedings.", - }, - ], - }, - - // ─── Credit Agreement ──────────────────────────────────────────────────────── - { - id: "builtin-credit-agreement", - user_id: null, - is_system: true, - created_at: "", - title: "Credit Agreement Review", - type: "tabular", - practice: "Finance", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Lenders", - format: "bulleted_list", - prompt: "Identify all lenders (or the lender syndicate) named in this agreement. For each, state their full legal name and role (e.g. mandated lead arranger, original lender, agent bank).", - }, - { - index: 1, - name: "Borrowers", - format: "bulleted_list", - prompt: "Identify all borrowers named in this agreement, including their full legal name and jurisdiction of incorporation.", - }, - { - index: 2, - name: "Guarantors", - format: "bulleted_list", - prompt: "Identify all guarantors named in this agreement, including their full legal name and the scope of their guarantee obligation.", - }, - { - index: 3, - name: "Other Parties", - format: "bulleted_list", - prompt: "Identify any other material parties to this agreement (e.g. facility agent, security agent, hedge counterparties, issuing bank). State their name and role.", - }, - { - index: 4, - name: "Date of Agreement", - format: "date", - prompt: "What is the date of this credit agreement?", - }, - { - index: 5, - name: "Facility", - format: "bulleted_list", - prompt: "List each facility available under this agreement (e.g. Revolving Credit Facility, Term Loan A, Term Loan B, Term Loan C). For each, state the facility type, tranche name, and any key structural features.", - }, - { - index: 6, - name: "Amount", - format: "monetary_amount", - prompt: "What is the total committed amount available under this agreement across all facilities? State the amount, currency, and breakdown by tranche if applicable.", - }, - { - index: 7, - name: "Purpose", - format: "text", - prompt: "What is the stated purpose for which borrowings under this agreement may be used? Identify any restrictions on use of proceeds.", - }, - { - index: 8, - name: "Interest", - format: "text", - prompt: "What interest rate applies to borrowings under this agreement? Identify the applicable rate (e.g. SOFR, EURIBOR, base rate), the margin, any margin ratchet mechanism, and how interest periods are structured.", - }, - { - index: 9, - name: "Commitment Fee", - format: "text", - prompt: "Is there a commitment fee or utilisation fee? If so, state the applicable rate, how it is calculated, and on what basis (e.g. undrawn commitment, average utilisation).", - }, - { - index: 10, - name: "Repayment Schedule", - format: "text", - prompt: "Summarise the repayment schedule for each facility. Identify whether repayment is by scheduled instalments or bullet repayment, and state the repayment dates and amounts where specified.", - }, - { - index: 11, - name: "Maturity", - format: "date", - prompt: "What is the final maturity date of the facilities under this agreement? If different facilities have different maturities, state each.", - }, - { - index: 12, - name: "Security", - format: "bulleted_list", - prompt: "What security is granted or required to be granted under this agreement? List each class of security (e.g. share pledges, fixed and floating charges, real estate mortgages, account pledges) and the assets or entities over which security is taken.", - }, - { - index: 13, - name: "Guarantees", - format: "bulleted_list", - prompt: "What guarantee obligations are given under or in connection with this agreement? Identify the guarantors, the scope of the guarantee, and any limitations (e.g. up-stream guarantee limitations, guarantor coverage test).", - }, - { - index: 14, - name: "Financial Covenants", - format: "bulleted_list", - prompt: "What financial covenants are included in this agreement? For each covenant identify the metric (e.g. leverage ratio, interest cover, cashflow cover), the applicable test, the testing frequency, and any equity cure rights.", - }, - { - index: 15, - name: "Events of Default", - format: "bulleted_list", - prompt: "List the events of default under this agreement. For each, note any grace periods, materiality thresholds, or cross-default provisions.", - }, - { - index: 16, - name: "Assignment", - format: "text", - prompt: "What restrictions or permissions apply to assignment or transfer of rights under this agreement? Identify restrictions on lender transfers (e.g. white/blacklists, borrower consent) and on borrower assignment.", - }, - { - index: 17, - name: "Change of Control", - format: "text", - prompt: "Is there a change of control provision? If so, what constitutes a change of control, what obligations does it trigger (e.g. mandatory prepayment, cancellation, lender consent), and is there any cure period?", - }, - { - index: 18, - name: "Prepayment Fee", - format: "text", - prompt: "Are there any prepayment fees, make-whole premiums, or soft-call protections? If so, state the applicable fee, the period during which it applies, and any exceptions (e.g. prepayment from insurance proceeds or asset disposal).", - }, - { - index: 19, - name: "Governing Law", - format: "text", - prompt: "What governing law applies to this agreement? State the jurisdiction and any specific legal system referenced.", - }, - { - index: 20, - name: "Dispute Resolution", - format: "text", - prompt: "How are disputes resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen forum or seat, and any submission to jurisdiction provisions.", - }, - ], - }, - - // ─── E-Discovery ───────────────────────────────────────────────────────────── - { - id: "builtin-ediscovery", - user_id: null, - is_system: true, - created_at: "", - title: "E-Discovery Review", - type: "tabular", - practice: "Litigation", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Date", - format: "date", - prompt: "What is the date of this document? For emails or correspondence, use the date sent. For other documents, use the date of creation, signature, or the most prominent date shown.", - }, - { - index: 1, - name: "Type of Document", - format: "text", - prompt: "What type of document is this? (e.g. email, memorandum, letter, contract, report, meeting minutes, text message, invoice, presentation). Be specific.", - }, - { - index: 2, - name: "Sender", - format: "text", - prompt: "Who is the sender or author of this document? State their full name, title, and organisation where identifiable.", - }, - { - index: 3, - name: "Recipient(s)", - format: "bulleted_list", - prompt: "Who are the recipients of this document? List all To, CC, and BCC recipients where identifiable. State their full name, title, and organisation for each. Note whether they appear in To, CC, or BCC fields.", - }, - { - index: 4, - name: "Summary", - format: "text", - prompt: "Provide a concise factual summary of the content of this document in 2–4 sentences. Focus on the key subject matter, any decisions made, actions requested, or information conveyed. Do not include legal conclusions.", - }, - { - index: 5, - name: "Persons Mentioned", - format: "bulleted_list", - prompt: "List all individuals mentioned in this document (other than the sender and recipients already identified). For each person, state their name and, if discernible, their role or organisation.", - }, - { - index: 6, - name: "Privileged?", - format: "yes_no", - prompt: "Does this document appear to be legally privileged? Answer Yes if it appears to be a communication between a lawyer and client made for the dominant purpose of obtaining or giving legal advice, or created for the dominant purpose of litigation. Answer No otherwise. If uncertain, note the basis for uncertainty.", - }, - ], - }, - - // ─── Supply Agreement ──────────────────────────────────────────────────────── - { - id: "builtin-supply-agreement", - user_id: null, - is_system: true, - created_at: "", - title: "Supply Agreement Review", - type: "tabular", - practice: "General Transactions", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Parties", - format: "bulleted_list", - prompt: "Identify all parties to this supply agreement. For each, state their full legal name, jurisdiction of incorporation (if stated), and their role (e.g. supplier, buyer, distributor).", - }, - { - index: 1, - name: "Effective Date", - format: "date", - prompt: "What is the effective date or commencement date of this agreement? If no explicit date is stated, note the date it is deemed to take effect.", - }, - { - index: 2, - name: "Products", - format: "bulleted_list", - prompt: "What products are to be supplied under this agreement? List each product or product category, including any relevant specifications, part numbers, or standards referenced.", - }, - { - index: 3, - name: "Term", - format: "text", - prompt: "What is the initial term or duration of this agreement? State the start date (or reference to when it commences) and the end date or duration.", - }, - { - index: 4, - name: "Renewal", - format: "text", - prompt: "What renewal provisions apply? Is renewal automatic or by agreement? State the renewal period, notice requirements to prevent renewal, and any conditions on renewal.", - }, - { - index: 5, - name: "Delivery", - format: "text", - prompt: "What delivery obligations and terms apply? Identify the delivery terms (e.g. Incoterms), delivery lead times, delivery locations, risk of loss, and any consequences for late or failed delivery.", - }, - { - index: 6, - name: "Quality", - format: "text", - prompt: "What quality standards or specifications apply to the products? Identify any applicable standards (e.g. ISO, regulatory requirements), inspection rights, acceptance procedures, and consequences of non-conformance.", - }, - { - index: 7, - name: "Warranties", - format: "text", - prompt: "What warranties does the supplier give in relation to the products? State the warranty period, the scope of the warranty (e.g. free from defects, conformance to specifications), the remedy for breach (e.g. repair, replacement, refund), and any exclusions.", - }, - { - index: 8, - name: "Liquidated Damages", - format: "text", - prompt: "Are there any liquidated damages provisions? If so, identify what triggers them (e.g. late delivery, failure to meet quality standards), the applicable rate or formula, any aggregate cap, and whether they are stated to be the exclusive remedy.", - }, - { - index: 9, - name: "Limitation of Liability", - format: "text", - prompt: "What limitations of liability apply? Identify any caps on liability (and how they are calculated, e.g. contract value, fees paid), exclusions of consequential or indirect loss, and any carve-outs from the limitation (e.g. fraud, wilful misconduct, death or personal injury).", - }, - { - index: 10, - name: "Force Majeure", - format: "text", - prompt: "Summarise the force majeure clause. What events qualify, what obligations are suspended, what notice must be given, how long must the event persist before either party may terminate, and what are the consequences of termination for force majeure?", - }, - { - index: 11, - name: "Termination Rights", - format: "text", - prompt: "What are the termination rights of each party? Distinguish between termination for convenience (including notice period) and termination for cause (including cure periods and triggers). Note what happens on termination, including any outstanding purchase orders or payment obligations.", - }, - { - index: 12, - name: "Governing Law", - format: "text", - prompt: "What governing law applies to this agreement? State the jurisdiction and any specific legal system referenced.", - }, - { - index: 13, - name: "Dispute Resolution", - format: "text", - prompt: "How are disputes resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen forum or seat, and any mandatory escalation steps (e.g. negotiation, mediation) before formal proceedings.", - }, - ], - }, - - // ─── SPA ───────────────────────────────────────────────────────────────────── - { - id: "builtin-spa", - user_id: null, - is_system: true, - created_at: "", - title: "SPA Review", - type: "tabular", - practice: "Corporate", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Parties", - format: "bulleted_list", - prompt: "Identify all parties to this share purchase agreement. For each, state their full legal name, jurisdiction of incorporation (if stated), and their role (e.g. seller, buyer, target company, warrantor, guarantor).", - }, - { - index: 1, - name: "Date", - format: "date", - prompt: "What is the date of this share purchase agreement?", - }, - { - index: 2, - name: "Transaction", - format: "text", - prompt: "Summarise the transaction. What shares or interests are being acquired, in which target company or companies, and what is the nature of the transaction (e.g. 100% acquisition, majority stake, minority investment)?", - }, - { - index: 3, - name: "Consideration", - format: "monetary_amount", - prompt: "What is the consideration payable under this agreement? State the total headline price, the currency, and the structure (e.g. cash, shares, loan notes, deferred consideration, earnout). If the price is subject to adjustment (e.g. locked box, completion accounts), describe the mechanism.", - }, - { - index: 4, - name: "Key Conditions Precedent", - format: "bulleted_list", - prompt: "List the key conditions precedent (CPs) to completion. For each CP, state what must be satisfied or waived and by whom. Identify any long-stop date by which CPs must be satisfied.", - }, - { - index: 5, - name: "Completion Date", - format: "text", - prompt: "When does completion occur? State how many business days after satisfaction or waiver of all CPs completion must occur, and/or any fixed outside date for completion. Note whether there is any obligation to complete by a specific date after signing.", - }, - { - index: 6, - name: "Warranties", - format: "text", - prompt: "Summarise the warranty package. Who gives the warranties (e.g. seller, management, all sellers jointly and severally)? Are there business warranties and/or title warranties? Identify the scope of any warranty disclosure process and any limitations on warranty claims (e.g. time limits, minimum claim thresholds, aggregate cap).", - }, - { - index: 7, - name: "Indemnities", - format: "text", - prompt: "Are there specific indemnities in this agreement? If so, list the key indemnities given, by whom, and for what potential liabilities (e.g. tax indemnity, environmental indemnity, litigation indemnity). Note any time limits or caps applicable to indemnity claims.", - }, - { - index: 8, - name: "Limitation of Liability", - format: "text", - prompt: "What limitations on liability apply to warranty and indemnity claims? Identify the aggregate cap (and how it is calculated, e.g. as a percentage of consideration), any separate cap for fundamental warranties or indemnities, minimum claim thresholds (de minimis and basket/deductible), and time limits for bringing claims.", - }, - { - index: 9, - name: "Covenants", - format: "text", - prompt: "What restrictive or other covenants are given by the seller or management? Include non-compete, non-solicitation, and non-dealing covenants, stating the scope (activities and geography) and duration of each.", - }, - { - index: 10, - name: "Exclusivity", - format: "text", - prompt: "Is there an exclusivity or no-shop provision in this agreement? If so, state the period of exclusivity, what activities are restricted (e.g. soliciting competing offers, engaging with third parties), and any carve-outs or break fee arrangements.", - }, - { - index: 11, - name: "Governing Law and Jurisdiction", - format: "text", - prompt: "What governing law applies to this agreement and what courts or arbitral tribunals have jurisdiction? State the chosen law, the forum for disputes, and whether jurisdiction is exclusive or non-exclusive.", - }, - { - index: 12, - name: "Dispute Resolution", - format: "text", - prompt: "How are disputes to be resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen seat or forum, the applicable rules (if arbitration), and any mandatory pre-dispute escalation steps.", - }, - ], - }, - - // ─── NDA ───────────────────────────────────────────────────────────────────── - { - id: "builtin-nda", - user_id: null, - is_system: true, - created_at: "", - title: "NDA Review", - type: "tabular", - practice: "General Transactions", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Direction", - format: "tag", - tags: ["Mutual", "Unilateral"], - prompt: "Is this NDA mutual (both parties owe confidentiality obligations to each other) or unilateral (only one party owes confidentiality obligations)? Identify the direction and name the disclosing and receiving party or parties.", - }, - { - index: 1, - name: "Definition of Confidential Information", - format: "text", - prompt: "How is 'Confidential Information' defined in this agreement? Is it broadly or narrowly drafted? Does it require information to be marked as confidential, or is all information shared in connection with the purpose automatically covered? Note any express inclusions or exclusions.", - }, - { - index: 2, - name: "Obligations of Receiving Party", - format: "bulleted_list", - prompt: "What are the key obligations of the receiving party in respect of the confidential information? List each obligation (e.g. keep confidential, not disclose to third parties, use only for the permitted purpose, apply a specific standard of care, restrict access to need-to-know personnel).", - }, - { - index: 3, - name: "Standard Carveouts Present?", - format: "yes_no", - prompt: "Does the agreement include the standard carveouts to confidentiality obligations? Answer Yes if the agreement excludes information that: (a) is or becomes publicly available without breach; (b) was already known to the receiving party; (c) is independently developed; and (d) is received from a third party without restriction. Note any carveouts that are missing or are drafted differently from the standard formulation.", - }, - { - index: 4, - name: "Permitted Disclosures", - format: "bulleted_list", - prompt: "To whom may the receiving party disclose confidential information? List each category of permitted recipient (e.g. employees, professional advisers, affiliates, financing parties, regulatory authorities). Note whether onward disclosure requires the recipient to be bound by equivalent obligations.", - }, - { - index: 5, - name: "Term and Duration", - format: "text", - prompt: "What is the term of this NDA and how long do the confidentiality obligations last? State the initial term of the agreement and the duration of the confidentiality obligations (noting whether they survive termination and for how long).", - }, - { - index: 6, - name: "Return and Destruction", - format: "text", - prompt: "What obligations apply on expiry or termination regarding return or destruction of confidential information? Is there a choice between return and destruction? Must destruction be certified? Are there any retention exceptions (e.g. for regulatory purposes, IT backup systems)?", - }, - { - index: 7, - name: "Remedies", - format: "text", - prompt: "What remedies are available for breach of the confidentiality obligations? Does the agreement acknowledge that damages may be inadequate and that injunctive relief or specific performance is available? Are there any agreed liquidated damages or indemnities for breach?", - }, - { - index: 8, - name: "Governing Law and Jurisdiction", - format: "text", - prompt: "What governing law applies to this agreement and which courts have jurisdiction? State the chosen law, the forum, and whether jurisdiction is exclusive or non-exclusive.", - }, - ], - }, - - // ─── Commercial Lease ───────────────────────────────────────────────────────── - { - id: "builtin-commercial-lease", - user_id: null, - is_system: true, - created_at: "", - title: "Commercial Lease Review", - type: "tabular", - practice: "Real Estate", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Landlord", - format: "text", - prompt: "Who is the landlord under this lease? State the full legal name, jurisdiction of incorporation or registration (if applicable), and any registered address or title number stated.", - }, - { - index: 1, - name: "Tenant", - format: "text", - prompt: "Who is the tenant under this lease? State the full legal name, jurisdiction of incorporation or registration (if applicable), and any registered address stated.", - }, - { - index: 2, - name: "Guarantor", - format: "text", - prompt: "Is there a guarantor under this lease? If so, state the guarantor's full legal name and the scope of the guarantee (e.g. full guarantee of the tenant's obligations, or limited to specific obligations). If there is no guarantor, state this explicitly.", - }, - { - index: 3, - name: "Premises", - format: "text", - prompt: "Describe the premises demised under this lease. Include the address, floor(s), unit reference, net internal area (if stated), and any areas included or excluded from the demise (e.g. common parts, roof, structure, car parking).", - }, - { - index: 4, - name: "Date of Lease", - format: "date", - prompt: "What is the date of this lease? If the lease is undated or if the term commencement date differs from the execution date, note both.", - }, - { - index: 5, - name: "Term", - format: "text", - prompt: "What is the contractual term of this lease? State the length of the term and the term commencement and expiry dates.", - }, - { - index: 6, - name: "Rent", - format: "monetary_amount", - prompt: "What is the initial annual rent payable under this lease? State the amount, the currency, the payment frequency (e.g. quarterly in advance), and the payment dates. Note any rent-free period or initial concessionary rent.", - }, - { - index: 7, - name: "Rent Review", - format: "text", - prompt: "Are there rent review provisions? If so, state the review dates or frequency, the review mechanism (e.g. open market rent review, RPI/CPI indexation, fixed uplift), whether the review is upward-only, any assumptions and disregards applicable to an open market review, and the dispute resolution mechanism if the parties cannot agree the reviewed rent.", - }, - { - index: 8, - name: "Service Charge", - format: "text", - prompt: "Is the tenant liable for a service charge? If so, describe what costs are included within the service charge, the tenant's apportionment or percentage share, any cap on the service charge, and how the service charge is administered and reconciled.", - }, - { - index: 9, - name: "Insurance", - format: "text", - prompt: "What are the insurance obligations under this lease? State who insures (landlord or tenant), what risks must be insured, who bears the insurance premium cost, and the tenant's obligations in respect of the landlord's insurance (e.g. not to vitiate the policy, to pay the premium as additional rent).", - }, - { - index: 10, - name: "Permitted Use", - format: "text", - prompt: "What is the permitted use of the premises under this lease? State the use class or specific use permitted and identify any restrictions on use. Note whether the landlord's consent is required to change use and on what basis consent may be withheld.", - }, - { - index: 11, - name: "Repair & Maintenance", - format: "text", - prompt: "Who is responsible for repair and maintenance of the premises? Describe the extent of the tenant's repairing obligation (e.g. full repairing, internal repairing only, subject to a schedule of condition). State the landlord's repairing obligations, if any, in respect of the structure, exterior, or common parts.", - }, - { - index: 12, - name: "Alterations", - format: "text", - prompt: "What alterations may the tenant make to the premises? Distinguish between structural and non-structural alterations. Is landlord consent required, and if so on what basis may it be withheld? Must the tenant reinstate alterations at the end of the term?", - }, - { - index: 13, - name: "Assignment & Subletting", - format: "text", - prompt: "What rights does the tenant have to assign or sublet the premises? State whether assignment and subletting are permitted with landlord consent, on what grounds consent may be withheld, any conditions to be satisfied (e.g. an authorised guarantee agreement on assignment, rent at no less than the passing rent on subletting), and whether any dealings are prohibited outright.", - }, - { - index: 14, - name: "Break Rights", - format: "text", - prompt: "Are there any break rights in this lease? If so, identify who holds the break right (landlord, tenant, or both), the break date(s), the notice period and form required to exercise the break, and any pre-conditions to effective exercise (e.g. no material breach, vacant possession, payment of all sums due).", - }, - { - index: 15, - name: "Security of Tenure", - format: "yes_no", - prompt: "Does the tenant have statutory security of tenure (e.g. under the Landlord and Tenant Act 1954 in England and Wales, or equivalent legislation in another jurisdiction)? Answer Yes if the lease is contracted in or benefits from security of tenure. Answer No if the lease has been contracted out or if security of tenure does not apply. State the basis for your answer.", - }, - { - index: 16, - name: "Dilapidations", - format: "text", - prompt: "What dilapidations obligations apply at the end of the term? Describe the tenant's yield-up obligations (e.g. to deliver the premises in repair, to reinstate alterations, to redecorate). Is there a schedule of condition limiting the tenant's liability? Note any dilapidations cap or other limitation on the landlord's claim.", - }, - { - index: 17, - name: "Rent Deposit", - format: "monetary_amount", - prompt: "Is a rent deposit required? If so, state the amount, the period for which it is held, the conditions under which the landlord may draw on it, and the circumstances in which it is returned to the tenant.", - }, - { - index: 18, - name: "Forfeiture & Termination", - format: "text", - prompt: "What are the landlord's forfeiture or termination rights? Identify the events that entitle the landlord to forfeit the lease (e.g. non-payment of rent after a grace period, material breach of covenant, insolvency) and any notice requirements before forfeiture can be exercised.", - }, - { - index: 19, - name: "Governing Law", - format: "text", - prompt: "What governing law applies to this lease and which courts have jurisdiction over disputes?", - }, - ], - }, - - // ─── Limited Partnership Agreement ─────────────────────────────────────────── - { - id: "builtin-lpa", - user_id: null, - is_system: true, - created_at: "", - title: "Limited Partnership Agreement Review", - type: "tabular", - practice: "Private Equity", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "General Partner", - format: "text", - prompt: "Identify the General Partner(s) of the fund. State the full legal name, jurisdiction of establishment, and any affiliated management entity (e.g. the fund manager or investment adviser) named in the agreement.", - }, - { - index: 1, - name: "Fund Name & Jurisdiction", - format: "text", - prompt: "What is the full name of the fund and in which jurisdiction is the limited partnership established or registered?", - }, - { - index: 2, - name: "Total Committed Capital", - format: "monetary_amount", - prompt: "What is the total committed capital of the fund? State the target size, any hard cap, the currency, and the closing date or dates if specified.", - }, - { - index: 3, - name: "Capital Calls & Drawdowns", - format: "text", - prompt: "How and when may the GP call capital from LPs? State the notice period for capital calls, the mechanics for issuing a call notice, any limit on the frequency or size of calls, and whether undrawn commitments can be recalled after repayment.", - }, - { - index: 4, - name: "Penalties for Failure to Fund", - format: "text", - prompt: "What are the consequences if an LP fails to fund a capital call? Describe any penalties (e.g. interest on the shortfall, dilution of interest, forced transfer at a discount, loss of voting or distribution rights, exclusion from future investments). Are there any cure periods before penalties apply?", - }, - { - index: 5, - name: "Investment Scope & Restrictions", - format: "text", - prompt: "What is the fund's stated investment strategy, scope, and any restrictions? Include permitted sectors, geographies, investment stages, instrument types, and any concentration limits (e.g. maximum % of committed capital per single investment). Note how much discretion the GP has to deviate from the stated strategy.", - }, - { - index: 6, - name: "Fund Term", - format: "text", - prompt: "What is the term of the fund? State the initial term (e.g. 10 years from final closing), any permitted extension periods (e.g. 2 × 1-year extensions), who has the right to approve extensions (GP alone or with LP/LPAC consent), and any early termination mechanics.", - }, - { - index: 7, - name: "Management Fee", - format: "text", - prompt: "What management fee is payable to the GP or manager? State the fee rate, the basis on which it is calculated (e.g. committed capital during the investment period, then invested or net asset value thereafter), any step-downs over the fund life, and the payment frequency.", - }, - { - index: 8, - name: "Carried Interest", - format: "text", - prompt: "What carried interest (carry) is payable to the GP? State the carry percentage, the structure (European/fund-level waterfall vs American/deal-by-deal), and identify each step of the distribution waterfall in sequence (e.g. return of capital, preferred return, GP catch-up, then profit split).", - }, - { - index: 9, - name: "Preferred Return (Hurdle Rate)", - format: "percentage", - prompt: "Is there a preferred return or hurdle rate that LPs must receive before the GP earns carry? State the rate, whether it is compounded (and on what basis), and how it is calculated (e.g. on invested capital, on contributed capital). If there is no preferred return, state this explicitly.", - }, - { - index: 10, - name: "GP Catch-Up", - format: "text", - prompt: "Is there a GP catch-up mechanism after the preferred return is met? If so, describe how it operates: what percentage of distributions go to the GP during the catch-up, and what economic result the catch-up is designed to achieve (e.g. the GP receives 20% of all profits to date).", - }, - { - index: 11, - name: "Clawback", - format: "text", - prompt: "Is there a clawback obligation on the GP if it receives excess carry? State whether the clawback is calculated at fund level or individual partner level, when it is triggered, any cap or limit on the clawback obligation, and whether there is any escrow or security arrangement to support the GP's clawback obligation.", - }, - { - index: 12, - name: "Fees & Expenses (Beyond Management Fee)", - format: "bulleted_list", - prompt: "What fees and expenses are charged to the fund or LPs beyond the management fee? List each category (e.g. transaction fees, monitoring fees, broken deal costs, formation expenses, legal fees, fund administration costs, organisational expenses). For each, state who bears the cost and whether any amounts are offset against the management fee.", - }, - { - index: 13, - name: "Distributions", - format: "text", - prompt: "How and when are distributions made to LPs? Describe the timing of distributions (e.g. upon realisation of investments or at the GP's discretion), whether the GP can reinvest proceeds within the investment period, and whether distributions may be made in-kind (i.e. as securities rather than cash).", - }, - { - index: 14, - name: "Key Person Clause", - format: "text", - prompt: "Is there a key person clause? Identify the designated key persons. What triggers the key person event (e.g. departure, incapacity, reduced time commitment below a threshold)? What are the consequences (e.g. suspension of the investment period)? Do LPs have any right to terminate or vote on continuation following a key person event?", - }, - { - index: 15, - name: "Removal of the GP", - format: "text", - prompt: "Under what circumstances can the GP be removed? Distinguish between removal for cause (e.g. fraud, gross negligence, wilful misconduct — state the LP voting threshold required) and removal without cause (state the LP voting threshold and any associated consequences such as carried interest treatment on removal).", - }, - { - index: 16, - name: "Advisory Committee (LPAC)", - format: "text", - prompt: "Is there an LP Advisory Committee (LPAC) or similar governance body? If so, describe its composition, how members are selected, its key powers and responsibilities (e.g. approving conflicts of interest, valuations, extensions, related-party transactions), and whether its approval is binding or merely advisory.", - }, - { - index: 17, - name: "Transfer Restrictions", - format: "text", - prompt: "What restrictions apply to an LP transferring or assigning its interest in the fund? Is GP consent required? Are there any permitted transfer exceptions (e.g. to affiliates)? Are secondary market sales permitted and, if so, subject to what conditions or rights of first refusal?", - }, - { - index: 18, - name: "Conflicts of Interest", - format: "text", - prompt: "How does the agreement address conflicts of interest? Describe the deal allocation policy across funds, any co-investment rights granted to LPs, restrictions on related-party transactions, and the role of the LPAC in reviewing or approving conflicts. Note any specific conflict scenarios expressly contemplated.", - }, - { - index: 19, - name: "Governing Law", - format: "text", - prompt: "What governing law applies to this agreement and which courts or arbitral tribunals have jurisdiction over disputes?", - }, - ], - }, - - // ─── Shareholder Agreement (Assistant) ─────────────────────────────────────── - { - id: "builtin-sha-summary", - user_id: null, - is_system: true, - created_at: "", - title: "Shareholder Agreement Summary", - type: "assistant", - practice: "Corporate", - 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.", - columns_config: null, - }, - - // ─── Shareholder Agreement ──────────────────────────────────────────────────── - { - id: "builtin-shareholder-agreement", - user_id: null, - is_system: true, - created_at: "", - title: "Shareholder Agreement Review", - type: "tabular", - practice: "Corporate", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Parties", - format: "bulleted_list", - prompt: "Identify all parties to this shareholder agreement. For each, state their full legal name, jurisdiction of incorporation or establishment (if stated), and their role (e.g. company, majority shareholder, minority shareholder, investor, founder, management shareholder).", - }, - { - index: 1, - name: "Date", - format: "date", - prompt: "What is the date of this shareholder agreement?", - }, - { - index: 2, - name: "Share Capital & Classes", - format: "bulleted_list", - prompt: "What classes of shares are in issue or contemplated by this agreement? For each class, describe the key rights attaching to it including voting rights, dividend rights, liquidation preference (if any), and any conversion or redemption features.", - }, - { - index: 3, - name: "Shareholdings", - format: "bulleted_list", - prompt: "What are the shareholdings of each party as set out or contemplated in this agreement? For each shareholder, state the number of shares held, the class, and the percentage of total share capital (on a fully diluted basis if stated).", - }, - { - index: 4, - name: "Board Composition", - format: "text", - prompt: "How is the board of directors constituted under this agreement? State the total number of directors, each shareholder's or class of shareholders' right to appoint or nominate directors (and the threshold shareholding required to maintain that right), and any provisions for a chairman or casting vote.", - }, - { - index: 5, - name: "Reserved Matters", - format: "bulleted_list", - prompt: "What are the reserved matters or veto rights set out in this agreement? List each matter that requires shareholder or director approval beyond an ordinary majority (e.g. special majority, unanimity, or the consent of a specific shareholder). Identify the applicable threshold or whose consent is required for each.", - }, - { - index: 6, - name: "Pre-emption on New Shares", - format: "text", - prompt: "What pre-emption rights apply on the issuance of new shares? Describe who holds pre-emption rights, the procedure for offering new shares to existing shareholders, the timeline for acceptance, and any carve-outs or exceptions (e.g. shares issued under an employee option scheme, permitted issuances).", - }, - { - index: 7, - name: "Transfer Restrictions", - format: "text", - prompt: "What restrictions apply to the transfer of shares? Identify any lock-up periods (and their duration), which transfers are prohibited outright, and which transfers are permitted without consent (e.g. transfers to affiliates or family trusts). Note any board or shareholder approval requirements for transfers.", - }, - { - index: 8, - name: "Right of First Refusal / Pre-emption on Transfer", - format: "text", - prompt: "Is there a right of first refusal or pre-emption right on a proposed transfer of shares? If so, describe who holds the right, the procedure for triggering and exercising it (including notice periods and pricing mechanics), and any exceptions.", - }, - { - index: 9, - name: "Drag-Along Rights", - format: "text", - prompt: "Are there drag-along rights? If so, identify who holds the drag right (e.g. majority shareholders above a specified threshold), the threshold required to trigger a drag, the obligations imposed on dragged shareholders, any conditions on the drag (e.g. minimum price, independent valuation), and any protections for minority shareholders.", - }, - { - index: 10, - name: "Tag-Along Rights", - format: "text", - prompt: "Are there tag-along rights? If so, identify who holds the tag right, the threshold transfer that triggers the tag, the procedure for exercising the tag (including notice periods), the price and terms on which the tagging shareholder may sell, and any exceptions.", - }, - { - index: 11, - name: "Anti-Dilution Protections", - format: "text", - prompt: "Are there any anti-dilution protections for any class of shareholders? If so, describe the type of protection (e.g. full ratchet, weighted average, broad-based or narrow-based), the trigger events, how the adjusted price or entitlement is calculated, and any exceptions (e.g. permitted issuances excluded from the calculation).", - }, - { - index: 12, - name: "Dividend Policy", - format: "text", - prompt: "What dividend provisions are set out in this agreement? Describe any obligation or policy to pay dividends (e.g. a minimum percentage of distributable profits), any preferential dividend rights attaching to a particular class of shares, and any restrictions on dividend payments (e.g. subject to available profits, board or shareholder approval, lender consent).", - }, - { - index: 13, - name: "Exit & Liquidity Provisions", - format: "text", - prompt: "What exit or liquidity provisions are included? Describe any agreed exit mechanisms (e.g. trade sale, IPO, drag-along sale), any timelines or milestones by which an exit is targeted, any shareholder rights to initiate or compel an exit process after a specified period, and any preference on exit proceeds attaching to a particular class of shares.", - }, - { - index: 14, - name: "Deadlock", - format: "text", - prompt: "How is deadlock addressed? Describe any deadlock resolution mechanisms (e.g. escalation to senior management, mediation, Russian roulette / shoot-out provisions, put/call options). For each mechanism, state the trigger conditions, the procedure, and the consequences if deadlock is not resolved.", - }, - { - index: 15, - name: "Non-Compete & Non-Solicitation", - format: "text", - prompt: "Are any shareholders subject to non-compete or non-solicitation obligations? If so, identify which shareholders are bound, the scope of the restriction (activities and geography), and the duration (during the term of the agreement and/or for a period after a shareholder ceases to hold shares). Note any carve-outs.", - }, - { - index: 16, - name: "Confidentiality", - format: "text", - prompt: "What confidentiality obligations are imposed on the shareholders? State the scope of confidential information covered, the permitted disclosures (e.g. to professional advisers, affiliates, lenders), and the duration of the obligation. Note whether the obligation survives termination of the agreement.", - }, - { - index: 17, - name: "Warranties", - format: "text", - prompt: "What warranties are given by the shareholders under this agreement? Identify who gives warranties, the subject matter (e.g. title to shares, capacity, no encumbrances, no conflicts), any limitations on warranty claims (e.g. time limits, caps, knowledge qualifications), and any indemnities given alongside the warranties.", - }, - { - index: 18, - name: "Governing Law", - format: "text", - prompt: "What governing law applies to this agreement? State the jurisdiction and any specific legal system referenced.", - }, - { - index: 19, - name: "Dispute Resolution", - format: "text", - prompt: "How are disputes resolved under this agreement? Identify whether disputes go to litigation or arbitration, the chosen forum or seat, any mandatory escalation steps, and whether jurisdiction is exclusive.", - }, - ], - }, - - // ─── Employment Agreement ───────────────────────────────────────────────────── - { - id: "builtin-employment-agreement", - user_id: null, - is_system: true, - created_at: "", - title: "Employment Agreement Review", - type: "tabular", - practice: "Employment", - prompt_md: null, - columns_config: [ - { - index: 0, - name: "Employer", - format: "text", - prompt: "Who is the employer under this agreement? State the full legal name and jurisdiction of incorporation or establishment.", - }, - { - index: 1, - name: "Employee", - format: "text", - prompt: "Who is the employee under this agreement? State their full name and, if provided, their address or location.", - }, - { - index: 2, - name: "Date", - format: "date", - prompt: "What is the date of this employment agreement? If a commencement date or start date differs from the signing date, state both.", - }, - { - index: 3, - name: "Title", - format: "text", - prompt: "What is the employee's job title or position as stated in this agreement? If a reporting line is specified, include it.", - }, - { - index: 4, - name: "Compensation", - format: "text", - prompt: "What is the employee's compensation under this agreement? State the base salary or wage, the currency, and the payment frequency (e.g. monthly, bi-weekly). Include any guaranteed bonus, commission, or other fixed remuneration elements.", - }, - { - index: 5, - name: "Full Time / Part Time", - format: "tag", - tags: ["Full Time", "Part Time"], - prompt: "Is this a full-time or part-time position? If part-time, state the number of days or hours per week where specified.", - }, - { - index: 6, - name: "Independent Contractor?", - format: "yes_no", - prompt: "Does the agreement characterise the worker as an independent contractor rather than an employee? Answer Yes if the agreement uses contractor, consultant, or self-employed language. Note any provisions that address the nature of the relationship.", - }, - { - index: 7, - name: "Benefits", - format: "bulleted_list", - prompt: "What benefits are the employee entitled to under this agreement? List each benefit (e.g. health insurance, pension/retirement contributions, life assurance, car allowance, share options, expense reimbursement). Note any eligibility conditions or limits.", - }, - { - index: 8, - name: "Notice Period (Employer to Employee)", - format: "text", - prompt: "What notice must the employer give to terminate the employee's employment (other than for cause)? State the notice period and any provisions for payment in lieu of notice.", - }, - { - index: 9, - name: "Notice Period (Employee to Employer)", - format: "text", - prompt: "What notice must the employee give to resign? State the notice period and any provisions for payment in lieu of notice or garden leave.", - }, - { - index: 10, - name: "Overtime", - format: "text", - prompt: "What provisions apply to overtime? Is the employee eligible for overtime pay, and if so at what rate? Or does the agreement state that the salary is inclusive of any overtime? Note any opt-out of statutory working time limits.", - }, - { - index: 11, - name: "Working Hours", - format: "text", - prompt: "What working hours are specified in this agreement? State the normal hours of work, any flexibility provisions, and whether the employee is expected to work additional hours as required.", - }, - { - index: 12, - name: "Variation", - format: "text", - prompt: "What provisions govern variation of the terms of this agreement? Can the employer unilaterally vary terms, or is the employee's consent required? Note any specific terms that are stated to be variable without consent.", - }, - { - index: 13, - name: "Intellectual Property Assignment", - format: "text", - prompt: "What intellectual property assignment provisions are included? Does the employee assign to the employer all IP created in the course of employment? Are there any carve-outs for pre-existing IP or inventions created outside working hours? Note any moral rights waiver.", - }, - { - index: 14, - name: "Grounds for Termination", - format: "bulleted_list", - prompt: "What grounds for summary dismissal or termination for cause are set out in the agreement? List each ground (e.g. gross misconduct, breach of confidentiality, insolvency, criminal conviction). Note whether summary dismissal is without notice or payment in lieu.", - }, - { - index: 15, - name: "Annual Leave Entitlement", - format: "text", - prompt: "What is the employee's annual leave entitlement? State the number of days (or weeks) per year, whether this is inclusive of or in addition to public holidays, and any provisions for accrual, carry-over, or payment of untaken leave on termination.", - }, - ], - }, -]; - -export const BUILT_IN_IDS = new Set(BUILT_IN_WORKFLOWS.map((wf) => wf.id)); diff --git a/frontend/src/app/components/workflows/practices.ts b/frontend/src/app/components/workflows/practices.ts index 54a2c17..3ecb5ab 100644 --- a/frontend/src/app/components/workflows/practices.ts +++ b/frontend/src/app/components/workflows/practices.ts @@ -17,7 +17,7 @@ export const PRACTICE_OPTIONS = [ "DCM", "Lev Fin", "Arbitration", - "Others", + "Other", ] as const; export type Practice = (typeof PRACTICE_OPTIONS)[number]; diff --git a/frontend/src/app/components/workflows/workflowRoutes.ts b/frontend/src/app/components/workflows/workflowRoutes.ts index 55337a0..0a1d2e8 100644 --- a/frontend/src/app/components/workflows/workflowRoutes.ts +++ b/frontend/src/app/components/workflows/workflowRoutes.ts @@ -1,7 +1,9 @@ import type { Workflow } from "../shared/types"; -export function workflowDetailPath(workflow: Pick<Workflow, "id" | "type">) { - return workflow.type === "assistant" +export function workflowDetailPath( + workflow: Pick<Workflow, "id" | "metadata">, +) { + return workflow.metadata.type === "assistant" ? `/workflows/assistant/${workflow.id}` : `/workflows/tabular-review/${workflow.id}`; } diff --git a/frontend/src/app/components/workflows/workflowZipExport.ts b/frontend/src/app/components/workflows/workflowZipExport.ts new file mode 100644 index 0000000..73564d1 --- /dev/null +++ b/frontend/src/app/components/workflows/workflowZipExport.ts @@ -0,0 +1,277 @@ +import type { ColumnConfig, Workflow } from "@/app/components/shared/types"; + +type ZipFile = { + path: string; + content: string; +}; + +const textEncoder = new TextEncoder(); + +const TABLE_CONFIG_SCHEMA = "../schema/table-config.schema.yaml"; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c >>> 0; + } + return table; +})(); + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function uint16(value: number): Uint8Array { + const bytes = new Uint8Array(2); + new DataView(bytes.buffer).setUint16(0, value, true); + return bytes; +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value, true); + return bytes; +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +function dosDateTime(date = new Date()): { date: number; time: number } { + const year = Math.max(1980, date.getFullYear()); + return { + time: + (date.getHours() << 11) | + (date.getMinutes() << 5) | + Math.floor(date.getSeconds() / 2), + date: + ((year - 1980) << 9) | + ((date.getMonth() + 1) << 5) | + date.getDate(), + }; +} + +function createZip(files: ZipFile[]): Blob { + const localParts: Uint8Array[] = []; + const centralParts: Uint8Array[] = []; + const { date, time } = dosDateTime(); + let offset = 0; + + for (const file of files) { + const name = textEncoder.encode(file.path); + const content = textEncoder.encode(file.content); + const crc = crc32(content); + const localHeader = concatBytes([ + uint32(0x04034b50), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(time), + uint16(date), + uint32(crc), + uint32(content.length), + uint32(content.length), + uint16(name.length), + uint16(0), + name, + ]); + const centralHeader = concatBytes([ + uint32(0x02014b50), + uint16(20), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(time), + uint16(date), + uint32(crc), + uint32(content.length), + uint32(content.length), + uint16(name.length), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(0), + uint32(offset), + name, + ]); + + localParts.push(localHeader, content); + centralParts.push(centralHeader); + offset += localHeader.length + content.length; + } + + const centralDirectory = concatBytes(centralParts); + const endOfCentralDirectory = concatBytes([ + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(files.length), + uint16(files.length), + uint32(centralDirectory.length), + uint32(offset), + uint16(0), + ]); + + const zipBytes = concatBytes([ + ...localParts, + centralDirectory, + endOfCentralDirectory, + ]); + return new Blob([toArrayBuffer(zipBytes)], { + type: "application/zip", + }); +} + +function slugify(input: string, fallback: string): string { + const slug = input + .trim() + .toLowerCase() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || fallback; +} + +function yamlScalar(value: string | null): string { + if (value === null) return "null"; + return JSON.stringify(value); +} + +function yamlBlock(value: string): string { + return value + .replace(/\r\n/g, "\n") + .trim() + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +} + +function tableConfigYaml(columns: ColumnConfig[]): string { + const lines = [`$schema: ${yamlScalar(TABLE_CONFIG_SCHEMA)}`, "columns_config:"]; + columns + .slice() + .sort((a, b) => a.index - b.index) + .forEach((column) => { + lines.push(` - index: ${column.index}`); + lines.push(` name: ${yamlScalar(column.name)}`); + if (column.format) { + lines.push(` format: ${yamlScalar(column.format)}`); + } + if (column.tags?.length) { + lines.push(" tags:"); + column.tags.forEach((tag) => + lines.push(` - ${yamlScalar(tag)}`), + ); + } + lines.push(" prompt: >-"); + lines.push(yamlBlock(column.prompt)); + }); + return `${lines.join("\n")}\n`; +} + +function skillFrontmatter(workflow: Workflow, slug: string): string { + const contributors = + workflow.metadata.contributors.length > 0 + ? workflow.metadata.contributors + : [ + { + name: "User", + organisation: null, + role: null, + linkedin: null, + }, + ]; + const lines = [ + "---", + `name: ${yamlScalar(slug)}`, + `display_name: ${yamlScalar(workflow.metadata.title)}`, + `description: ${yamlScalar( + workflow.metadata.description ?? + `Run the ${workflow.metadata.title} workflow.`, + )}`, + `type: ${yamlScalar(workflow.metadata.type)}`, + `language: ${yamlScalar(workflow.metadata.language || "English")}`, + `version: ${yamlScalar(workflow.metadata.version || "1.0.0")}`, + `practice: ${yamlScalar(workflow.metadata.practice)}`, + "jurisdictions:", + ...(workflow.metadata.jurisdictions?.length + ? workflow.metadata.jurisdictions.map( + (jurisdiction) => ` - ${yamlScalar(jurisdiction)}`, + ) + : [" - \"General\""]), + "contributors:", + ...contributors.flatMap((contributor) => [ + ` - name: ${yamlScalar(contributor.name)}`, + ` organisation: ${yamlScalar(contributor.organisation)}`, + ` role: ${yamlScalar(contributor.role)}`, + ` linkedin: ${yamlScalar(contributor.linkedin)}`, + ]), + "---", + "", + ]; + return lines.join("\n"); +} + +function workflowFiles( + workflow: Workflow, + skillMd: string, + columns: ColumnConfig[], +): { files: ZipFile[]; slug: string } { + const type = workflow.metadata.type; + const slug = slugify(workflow.metadata.title, workflow.id || "workflow"); + const basePath = slug; + const files: ZipFile[] = [ + { + path: `${basePath}/SKILL.md`, + content: `${skillFrontmatter(workflow, slug)}${skillMd.trimEnd()}\n`, + }, + ]; + + if (type === "tabular") { + files.push({ + path: `${basePath}/table-config.yaml`, + content: tableConfigYaml(columns), + }); + } + + return { files, slug }; +} + +export function downloadWorkflowZip( + workflow: Workflow, + skillMd: string, + columns: ColumnConfig[], +) { + const { files, slug } = workflowFiles(workflow, skillMd, columns); + const blob = createZip(files); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${slug}.zip`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/app/contexts/AuthContext.tsx similarity index 98% rename from frontend/src/contexts/AuthContext.tsx rename to frontend/src/app/contexts/AuthContext.tsx index c1ce65e..6199895 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/app/contexts/AuthContext.tsx @@ -8,7 +8,7 @@ import React, { ReactNode, } from "react"; import type { User as SupabaseUser } from "@supabase/supabase-js"; -import { supabase } from "@/lib/supabase"; +import { supabase } from "@/app/lib/supabase"; interface User { id: string; diff --git a/frontend/src/app/contexts/ChatHistoryContext.tsx b/frontend/src/app/contexts/ChatHistoryContext.tsx index 83f6d9d..718fc5f 100644 --- a/frontend/src/app/contexts/ChatHistoryContext.tsx +++ b/frontend/src/app/contexts/ChatHistoryContext.tsx @@ -9,7 +9,7 @@ import { useState, type ReactNode, } from "react"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/app/contexts/AuthContext"; import { createChat, deleteChat, diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/app/contexts/UserProfileContext.tsx similarity index 99% rename from frontend/src/contexts/UserProfileContext.tsx rename to frontend/src/app/contexts/UserProfileContext.tsx index 735dccb..42d168a 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/app/contexts/UserProfileContext.tsx @@ -8,7 +8,7 @@ import React, { ReactNode, useCallback, } from "react"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/app/contexts/AuthContext"; import { type ApiKeyState, type ApiKeyProvider, diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx index 2a9da5d..fcaf21a 100644 --- a/frontend/src/app/error.tsx +++ b/frontend/src/app/error.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useEffect } from "react"; +import { PillButton } from "@/app/components/ui/pill-button"; export default function Error({ error, @@ -23,12 +24,9 @@ export default function Error({ our team will look into it. </p> - <Link - href="/" - className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-medium text-white bg-gray-900 hover:bg-gray-700 transition-colors" - > - Home - </Link> + <PillButton asChild tone="black" size="normal"> + <Link href="/">Home</Link> + </PillButton> </div> </div> ); diff --git a/frontend/src/app/global-error.tsx b/frontend/src/app/global-error.tsx index ccc1d3d..f72e105 100644 --- a/frontend/src/app/global-error.tsx +++ b/frontend/src/app/global-error.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import { PillButton } from "@/app/components/ui/pill-button"; export default function GlobalError({ error, @@ -51,30 +52,7 @@ export default function GlobalError({ margin-bottom: 2rem; } - .btn-back { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.625rem 1.25rem; - border-radius: 0.5rem; - font-size: 0.875rem; - font-weight: 500; - font-family: 'Inter', sans-serif; - cursor: pointer; - transition: all 0.15s ease; - text-decoration: none; - border: none; - background-color: rgb(0, 136, 255); - color: white; - } - - .btn-back:hover { - background-color: rgb(0, 120, 230); - } - - .btn-back:active { - transform: scale(0.98); - } + .btn-back { font-family: 'Inter', sans-serif; } `}</style> </head> <body> @@ -84,12 +62,14 @@ export default function GlobalError({ We encountered an unexpected error. This has been logged and our team will look into it. </p> - <button + <PillButton + tone="blue" + size="normal" className="btn-back" onClick={() => window.history.back()} > Back - </button> + </PillButton> </div> </body> </html> diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f383967..129c60a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -343,27 +343,30 @@ } .workflow-editor-content h1 { - font-size: 1rem; + font-size: 1.25rem; font-weight: 600; color: #111827; margin-top: 1.25rem; margin-bottom: 0.375rem; + line-height: 1.25; } .workflow-editor-content h2 { - font-size: 0.875rem; + font-size: 1.0625rem; font-weight: 600; color: #111827; margin-top: 0.875rem; margin-bottom: 0.25rem; + line-height: 1.3; } .workflow-editor-content h3 { - font-size: 0.8125rem; + font-size: 0.9375rem; font-weight: 600; color: #111827; margin-top: 0.625rem; margin-bottom: 0.125rem; + line-height: 1.35; } .workflow-editor-content h1:first-child, @@ -409,6 +412,67 @@ font-style: italic; } +.workflow-editor-content .tableWrapper { + margin: 0.75rem 0; + overflow-x: auto; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; +} + +.workflow-editor-content .tableWrapper:first-child { + margin-top: 0; +} + +.workflow-editor-content .tableWrapper:last-child { + margin-bottom: 0; +} + +.workflow-editor-content table { + width: 100%; + min-width: 100%; + border-collapse: collapse; + table-layout: fixed; + text-align: left; + font-size: 0.75rem; +} + +.workflow-editor-content thead { + background: #f9fafb; +} + +.workflow-editor-content th, +.workflow-editor-content td { + border-right: 1px solid #f3f4f6; + border-bottom: 1px solid #f3f4f6; + padding: 0.5rem 0.75rem; + vertical-align: top; + word-break: break-word; +} + +.workflow-editor-content th:last-child, +.workflow-editor-content td:last-child { + border-right: 0; +} + +.workflow-editor-content tr:last-child td { + border-bottom: 0; +} + +.workflow-editor-content th { + color: #374151; + font-weight: 600; +} + +.workflow-editor-content td { + color: #4b5563; +} + +.workflow-editor-content th p, +.workflow-editor-content td p { + min-height: 1.25rem; + margin-bottom: 0; +} + /* ChatBox Animation Styles */ /* PDF text layer */ diff --git a/frontend/src/app/hooks/useAssistantChat.ts b/frontend/src/app/hooks/useAssistantChat.ts index 7afef1c..62a0085 100644 --- a/frontend/src/app/hooks/useAssistantChat.ts +++ b/frontend/src/app/hooks/useAssistantChat.ts @@ -10,7 +10,7 @@ import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; import { useGenerateChatTitle } from "./useGenerateChatTitle"; import type { AssistantEvent, - CitationAnnotation, + Citation, Message, } from "@/app/components/shared/types"; @@ -99,6 +99,21 @@ export function useAssistantChat({ const eventsRef = useRef<AssistantEvent[]>([]); + const updateLatestAssistantMessage = ( + updater: (message: Message) => Message, + ) => { + setMessages((prev) => { + const assistantIndex = [...prev] + .map((message, index) => ({ message, index })) + .reverse() + .find(({ message }) => message.role === "assistant")?.index; + if (assistantIndex === undefined) return prev; + const updated = [...prev]; + updated[assistantIndex] = updater(updated[assistantIndex]); + return updated; + }); + }; + /** * Finalize any in-flight streaming content event so the next * content_delta starts a fresh block. Called @@ -115,17 +130,10 @@ export function useAssistantChat({ { type: "content", text: last.text }, ]; const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const lastMsg = updated[updated.length - 1]; - if (lastMsg?.role === "assistant") { - updated[updated.length - 1] = { - ...lastMsg, - events: snapshot, - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + events: snapshot, + })); } }; @@ -141,17 +149,10 @@ export function useAssistantChat({ { type: "reasoning", text: last.text }, ]; const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const lastMsg = updated[updated.length - 1]; - if (lastMsg?.role === "assistant") { - updated[updated.length - 1] = { - ...lastMsg, - events: snapshot, - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + events: snapshot, + })); }; // Transient placeholder events (tool_call_start, thinking) fill the @@ -183,17 +184,10 @@ export function useAssistantChat({ abortControllerRef.current.abort(); const snapshot = cancelStreamingEvents(eventsRef.current); eventsRef.current = snapshot; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: cancelStreamingEvents(last.events ?? snapshot), - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + events: cancelStreamingEvents(message.events ?? snapshot), + })); setIsResponseLoading(false); setIsLoadingCitations(false); } @@ -205,14 +199,7 @@ export function useAssistantChat({ if (after.length === before.length) return; eventsRef.current = after; const snapshot = [...after]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { ...last, events: snapshot }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ ...message, events: snapshot })); }; const pushThinkingPlaceholder = () => { @@ -225,14 +212,7 @@ export function useAssistantChat({ { type: "thinking" as const, isStreaming: true }, ]; const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const lastMsg = updated[updated.length - 1]; - if (lastMsg?.role === "assistant") { - updated[updated.length - 1] = { ...lastMsg, events: snapshot }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ ...message, events: snapshot })); }; const pushEvent = (event: AssistantEvent) => { @@ -243,14 +223,7 @@ export function useAssistantChat({ const next = eventsRef.current.filter((e) => !isStreamingPlaceholder(e)); eventsRef.current = [...next, event]; const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { ...last, events: snapshot }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ ...message, events: snapshot })); }; const updateMatchingEvent = ( @@ -267,14 +240,7 @@ export function useAssistantChat({ newEvents[idx] = updater(events[idx]); eventsRef.current = newEvents; const snapshot = [...newEvents]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { ...last, events: snapshot }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ ...message, events: snapshot })); return true; }; @@ -282,6 +248,10 @@ export function useAssistantChat({ message: Message, opts?: { displayedDoc?: { filename: string; documentId: string } | null; + askInputsResponse?: Extract< + AssistantEvent, + { type: "ask_inputs_response" } + >; }, ): Promise<string | null> => { if (!message.content.trim()) return null; @@ -294,24 +264,62 @@ export function useAssistantChat({ lastMessage.role === "user" && lastMessage.content === message.content; - const newMessages: Message[] = isMessageAlreadyAdded + const apiMessagesForTurn: Message[] = isMessageAlreadyAdded ? messages : [...messages, message]; + const askInputsResponseEvent = opts?.askInputsResponse ?? null; + const optimisticResponseEvent = askInputsResponseEvent; + const userInputThinkingEvent = optimisticResponseEvent + ? ({ + type: "thinking" as const, + isStreaming: true, + } satisfies AssistantEvent) + : null; + const displayMessages: Message[] = optimisticResponseEvent + ? (() => { + const updated = messages.map((item) => ({ + ...item, + events: item.events ? [...item.events] : item.events, + })); + for (let i = updated.length - 1; i >= 0; i--) { + const current = updated[i]; + if (current.role !== "assistant") continue; + updated[i] = { + ...current, + events: [ + ...(current.events ?? []), + optimisticResponseEvent, + ...(userInputThinkingEvent ? [userInputThinkingEvent] : []), + ], + }; + return updated; + } + return updated; + })() + : apiMessagesForTurn; - setMessages([ - ...newMessages, - { role: "assistant", content: "", annotations: [], events: [] }, - ]); + setMessages( + optimisticResponseEvent + ? displayMessages + : [ + ...displayMessages, + { role: "assistant", content: "", citations: [], events: [] }, + ], + ); let streamedChatId: string | null = null; - eventsRef.current = []; + eventsRef.current = optimisticResponseEvent + ? ([...displayMessages] + .reverse() + .find((item) => item.role === "assistant")?.events ?? []) + : []; const controller = new AbortController(); abortControllerRef.current = controller; try { - const apiMessages = newMessages.map((currentMessage) => ({ + const apiMessages = apiMessagesForTurn.map((currentMessage) => ({ role: currentMessage.role, content: currentMessage.content, files: currentMessage.files, @@ -348,12 +356,14 @@ export function useAssistantChat({ : undefined, attached_documents: attachedDocs.length > 0 ? attachedDocs : undefined, + ask_inputs_response: opts?.askInputsResponse, signal: controller.signal, }) : streamChat({ messages: apiMessages, chat_id: chatId, model, + ask_inputs_response: opts?.askInputsResponse, signal: controller.signal, })); @@ -408,18 +418,11 @@ export function useAssistantChat({ { type: "error", message }, ]; const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: snapshot, - error: message, - }; - } - return updated; - }); + updateLatestAssistantMessage((assistantMessage) => ({ + ...assistantMessage, + events: snapshot, + error: message, + })); setIsResponseLoading(false); setIsLoadingCitations(false); continue; @@ -451,17 +454,10 @@ export function useAssistantChat({ }, ]; const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: snapshot, - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + events: snapshot, + })); } else { const nextEvents = [...events]; nextEvents[nextEvents.length - 1] = { @@ -471,17 +467,10 @@ export function useAssistantChat({ }; eventsRef.current = nextEvents; const snapshot = [...nextEvents]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: snapshot, - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + events: snapshot, + })); } continue; } @@ -516,17 +505,10 @@ export function useAssistantChat({ ]; } const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: snapshot, - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + events: snapshot, + })); continue; } @@ -543,17 +525,10 @@ export function useAssistantChat({ ]; } const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: snapshot, - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + events: snapshot, + })); pushThinkingPlaceholder(); continue; } @@ -904,6 +879,85 @@ export function useAssistantChat({ continue; } + if (data.type === "ask_inputs") { + const rawItems = Array.isArray(data.items) + ? (data.items as unknown[]) + : []; + const items = rawItems.reduce<Extract< + AssistantEvent, + { type: "ask_inputs" } + >["items"]>((acc, item, index) => { + if (!item || typeof item !== "object") return acc; + const row = item as Record<string, unknown>; + const id = + typeof row.id === "string" && row.id.trim() + ? row.id.trim() + : `input-${index + 1}`; + if (row.kind === "choice") { + const options = Array.isArray(row.options) + ? (row.options as unknown[]).flatMap((option) => { + if (!option || typeof option !== "object") return []; + const optionRow = option as Record<string, unknown>; + const value = + typeof optionRow.value === "string" + ? optionRow.value + : typeof optionRow.label === "string" + ? optionRow.label + : ""; + if (!value.trim()) return []; + return [ + { + value, + }, + ]; + }) + : []; + acc.push({ + id, + kind: "choice" as const, + question: + typeof row.question === "string" + ? row.question + : "Please choose an option.", + options, + allow_other: row.allow_other !== false, + other_label: + typeof row.other_label === "string" + ? row.other_label + : "Other", + response_prefix: + typeof row.response_prefix === "string" + ? row.response_prefix + : undefined, + }); + return acc; + } + if (row.kind === "documents") { + const documentTypes = Array.isArray(row.document_types) + ? (row.document_types as unknown[]) + .filter((type): type is string => typeof type === "string") + .map((type) => type.trim()) + .filter(Boolean) + : []; + acc.push({ + id, + kind: "documents" as const, + document_types: documentTypes, + response_prefix: + typeof row.response_prefix === "string" + ? row.response_prefix + : undefined, + }); + return acc; + } + return acc; + }, []); + if (items.length > 0) { + pushEvent({ type: "ask_inputs", items }); + } + continue; + } + if (data.type === "doc_read") { updateMatchingEvent( (e) => @@ -1096,41 +1150,27 @@ export function useAssistantChat({ ? data.status : "final"; const incoming = (data.citations ?? - []) as CitationAnnotation[]; + []) as Citation[]; if (status === "started" || status === "partial") { - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - annotations: incoming, - citationStatus: status, - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + citations: incoming, + citationStatus: status, + })); continue; } // End-of-stream signal — scrub any lingering // placeholders so they don't persist into the // finalised message. First finalize content so adding - // annotations cannot re-render the markdown/citation view + // citations cannot re-render the markdown/citation view // against a streaming block. finalizeStreamingContent(); clearStreamingPlaceholders(); - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - annotations: incoming, - citationStatus: incoming.length ? "final" : undefined, - }; - } - return updated; - }); + updateLatestAssistantMessage((message) => ({ + ...message, + citations: incoming, + citationStatus: incoming.length ? "final" : undefined, + })); continue; } } catch (e) { @@ -1166,7 +1206,7 @@ export function useAssistantChat({ await loadChats(); const finalChatIdForTitle = streamedChatId || chatId || null; - if (finalChatIdForTitle && newMessages.length === 1) { + if (finalChatIdForTitle && apiMessagesForTurn.length === 1) { const titleParts = [message.content]; if (message.workflow) titleParts.push(`Workflow: ${message.workflow.title}`); @@ -1184,15 +1224,19 @@ export function useAssistantChat({ finalizeStreamingReasoning(); eventsRef.current = appendCancellationEvent(eventsRef.current); setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "assistant") { - const updated = [...prev]; + const assistantIndex = [...prev] + .map((message, index) => ({ message, index })) + .reverse() + .find(({ message }) => message.role === "assistant")?.index; + if (assistantIndex !== undefined) { + const assistantMessage = prev[assistantIndex]; const events = appendCancellationEvent( - last.events ?? eventsRef.current, + assistantMessage.events ?? eventsRef.current, ); eventsRef.current = events; - updated[updated.length - 1] = { - ...last, + const updated = [...prev]; + updated[assistantIndex] = { + ...assistantMessage, events, }; return updated; @@ -1214,11 +1258,14 @@ export function useAssistantChat({ ? error.message : "Sorry, something went wrong."; setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "assistant") { + const assistantIndex = [...prev] + .map((message, index) => ({ message, index })) + .reverse() + .find(({ message }) => message.role === "assistant")?.index; + if (assistantIndex !== undefined) { const updated = [...prev]; - updated[updated.length - 1] = { - ...last, + updated[assistantIndex] = { + ...updated[assistantIndex], error: errorMessage, }; return updated; diff --git a/frontend/src/app/hooks/useDocumentVersions.ts b/frontend/src/app/hooks/useDocumentVersions.ts deleted file mode 100644 index b5f3af2..0000000 --- a/frontend/src/app/hooks/useDocumentVersions.ts +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { supabase } from "@/lib/supabase"; - -export interface DocumentVersionRow { - id: string; - version_number: number | null; - source: - | "upload" - | "assistant_edit" - | "user_accept" - | "user_reject" - | "generated"; - created_at: string; -} - -export interface DocumentVersionsResult { - versions: DocumentVersionRow[]; - currentVersionId: string | null; - loading: boolean; - error: string | null; - /** Refetch externally; used after accept/reject or new assistant edits. */ - refresh: () => void; -} - -/** - * Fetch the list of tracked versions for a document, with the numeric - * version_number we use in UI labels ("V1", "V2", …). - */ -export function useDocumentVersions( - documentId: string | null | undefined, - refreshKey?: number, -): DocumentVersionsResult { - const [versions, setVersions] = useState<DocumentVersionRow[]>([]); - const [currentVersionId, setCurrentVersionId] = useState<string | null>( - null, - ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - const [tick, setTick] = useState(0); - - useEffect(() => { - if (!documentId) { - setVersions([]); - setCurrentVersionId(null); - return; - } - let cancelled = false; - setLoading(true); - setError(null); - - (async () => { - 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/${documentId}/versions`, - { - headers: token - ? { Authorization: `Bearer ${token}` } - : {}, - }, - ); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = (await resp.json()) as { - versions: DocumentVersionRow[]; - current_version_id: string | null; - }; - if (cancelled) return; - setVersions(data.versions ?? []); - setCurrentVersionId(data.current_version_id ?? null); - } catch (e) { - if (!cancelled) - setError(e instanceof Error ? e.message : String(e)); - } finally { - if (!cancelled) setLoading(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [documentId, refreshKey, tick]); - - return { - versions, - currentVersionId, - loading, - error, - refresh: () => setTick((t) => t + 1), - }; -} diff --git a/frontend/src/app/hooks/useFetchDocxBytes.ts b/frontend/src/app/hooks/useFetchDocxBytes.ts index 27dd6a0..f74bf47 100644 --- a/frontend/src/app/hooks/useFetchDocxBytes.ts +++ b/frontend/src/app/hooks/useFetchDocxBytes.ts @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { supabase } from "@/lib/supabase"; +import { supabase } from "@/app/lib/supabase"; export interface FetchDocxResult { bytes: ArrayBuffer | null; diff --git a/frontend/src/app/hooks/useFetchSingleDoc.ts b/frontend/src/app/hooks/useFetchSingleDoc.ts index c2630c6..6c39c20 100644 --- a/frontend/src/app/hooks/useFetchSingleDoc.ts +++ b/frontend/src/app/hooks/useFetchSingleDoc.ts @@ -1,19 +1,28 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { supabase } from "@/lib/supabase"; +import { supabase } from "@/app/lib/supabase"; /** - * /display returns either PDF bytes (when the active version has a PDF - * rendition) or raw DOCX bytes otherwise. Reporting the type lets the - * caller swap between DocView (PDF.js) and DocxView (docx-preview) - * accordingly. + * /display returns PDF bytes (when the active version has a PDF rendition), + * raw spreadsheet bytes (xlsx/xlsm/xls — never converted to PDF), or raw DOCX + * bytes otherwise. Reporting the type lets the caller swap between PdfView + * (PDF.js), SpreadsheetView (Fortune-sheet), and DocxView (docx-preview). */ export type DocResult = | { type: "pdf"; buffer: ArrayBuffer } + | { type: "spreadsheet"; buffer: ArrayBuffer } | { type: "docx" } | null; +/** Office spreadsheet content types served raw by /display. */ +function isSpreadsheetContentType(contentType: string): boolean { + return ( + contentType.includes("spreadsheetml") || // .xlsx + contentType.includes("ms-excel") // .xls / .xlsm + ); +} + export function useFetchSingleDoc( documentId: string | null | undefined, versionId?: string | null, @@ -65,6 +74,9 @@ export function useFetchSingleDoc( if (contentType.includes("application/pdf")) { const buffer = await response.arrayBuffer(); if (!cancelled) setResult({ type: "pdf", buffer }); + } else if (isSpreadsheetContentType(contentType)) { + const buffer = await response.arrayBuffer(); + if (!cancelled) setResult({ type: "spreadsheet", buffer }); } else { // Drain the body so the connection is reusable, but the // bytes are useless to the PDF viewer — the caller will diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index fab9a97..6bb8f90 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Inter, EB_Garamond } from "next/font/google"; import "./globals.css"; -import { Providers } from "@/components/providers"; +import { Providers } from "@/app/components/providers"; const inter = Inter({ variable: "--font-inter", diff --git a/frontend/src/app/lib/documentUploadValidation.ts b/frontend/src/app/lib/documentUploadValidation.ts index 5866a0e..aa80197 100644 --- a/frontend/src/app/lib/documentUploadValidation.ts +++ b/frontend/src/app/lib/documentUploadValidation.ts @@ -1,8 +1,18 @@ -export const SUPPORTED_DOCUMENT_ACCEPT = ".pdf,.docx,.doc"; +export const SUPPORTED_DOCUMENT_ACCEPT = + ".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"; export const UNSUPPORTED_DOCUMENT_WARNING_MESSAGE = - "Unsupported file type. Only PDF, DOCX, and DOC files can be uploaded."; + "Unsupported file type. Only PDF, Word, Excel, and PowerPoint files can be uploaded."; -const SUPPORTED_DOCUMENT_EXTENSIONS = new Set(["pdf", "docx", "doc"]); +const SUPPORTED_DOCUMENT_EXTENSIONS = new Set([ + "pdf", + "docx", + "doc", + "xlsx", + "xlsm", + "xls", + "pptx", + "ppt", +]); export function isSupportedDocumentFile(file: File): boolean { const extension = file.name.split(".").pop()?.toLowerCase(); diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 0356a4b..a1fb42b 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -3,17 +3,20 @@ * Attaches the Supabase auth token for user authentication. */ -import { supabase } from "@/lib/supabase"; +import { supabase } from "@/app/lib/supabase"; import type { AssistantEvent, Chat, ChatDetailOut, - CitationAnnotation, + Citation, Document, Folder, Message, + OpenSourceWorkflowContributorMode, + OpenSourceWorkflowResponse, Project, Workflow, + WorkflowContributor, TabularReview, TabularReviewDetailOut, } from "@/app/components/shared/types"; @@ -26,7 +29,7 @@ interface ServerMessage { content: string | AssistantEvent[] | null; files?: { filename: string; document_id?: string }[] | null; workflow?: { id: string; title: string } | null; - annotations?: CitationAnnotation[] | null; + citations?: Citation[] | null; created_at: string; } interface ServerChatDetailOut { @@ -166,12 +169,13 @@ export async function listProjects(): Promise<Project[]> { export async function createProject( name: string, cm_number?: string, + practice?: string, shared_with?: string[], ): Promise<Project> { return apiRequest<Project>("/projects", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, cm_number, shared_with }), + body: JSON.stringify({ name, cm_number, practice, shared_with }), }); } @@ -226,10 +230,24 @@ export interface UserProfile { apiKeyStatus: ApiKeyStatus; } +export interface UserLookupResult { + exists: boolean; + email: string; + display_name: string | null; +} + export async function getUserProfile(): Promise<UserProfile> { return apiRequest<UserProfile>("/user/profile"); } +export async function lookupUserByEmail( + email: string, +): Promise<UserLookupResult> { + return apiRequest<UserLookupResult>( + `/user/lookup?email=${encodeURIComponent(email)}`, + ); +} + export async function updateUserProfile(payload: { displayName?: string | null; organisation?: string | null; @@ -411,6 +429,7 @@ export async function updateProject( payload: { name?: string; cm_number?: string; + practice?: string | null; shared_with?: string[]; }, ): Promise<Project> { @@ -750,6 +769,7 @@ export async function getChat(chatId: string): Promise<ChatDetailOut> { const messages: Message[] = raw.messages.map((m) => { if (m.role === "user") { return { + id: m.id, role: "user", content: typeof m.content === "string" ? m.content : "", files: m.files ?? undefined, @@ -760,13 +780,14 @@ export async function getChat(chatId: string): Promise<ChatDetailOut> { ? (m.content as AssistantEvent[]) : undefined; return { + id: m.id, role: "assistant", content: events ?.filter((e) => e.type === "content") .map((e) => (e as { type: "content"; text: string }).text) .join("") ?? "", - annotations: m.annotations ?? undefined, + citations: m.citations ?? undefined, events, }; }); @@ -832,6 +853,23 @@ export async function streamChat(payload: { chat_id?: string; project_id?: string; model?: string; + ask_inputs_response?: { + responses: ( + | { + id: string; + kind: "choice"; + question: string; + answer?: string; + skipped?: boolean; + } + | { + id: string; + kind: "documents"; + filenames: string[]; + skipped?: boolean; + } + )[]; + }; signal?: AbortSignal; }): Promise<Response> { const { signal, ...body } = payload; @@ -862,6 +900,23 @@ export async function streamProjectChat(payload: { model?: string; displayed_doc?: { filename: string; document_id: string }; attached_documents?: { filename: string; document_id: string }[]; + ask_inputs_response?: { + responses: ( + | { + id: string; + kind: "choice"; + question: string; + answer?: string; + skipped?: boolean; + } + | { + id: string; + kind: "documents"; + filenames: string[]; + skipped?: boolean; + } + )[]; + }; signal?: AbortSignal; }): Promise<Response> { const { projectId, signal, ...body } = payload; @@ -1121,7 +1176,7 @@ export async function clearTabularCells( // Workflows // --------------------------------------------------------------------------- -type WorkflowType = Workflow["type"]; +type WorkflowType = Workflow["metadata"]["type"]; export async function listWorkflows( type: WorkflowType, @@ -1134,11 +1189,15 @@ export async function getWorkflow(workflowId: string): Promise<Workflow> { } export async function createWorkflow(payload: { - title: string; - type: "assistant" | "tabular"; - prompt_md?: string; + metadata: { + title: string; + type: "assistant" | "tabular"; + language?: string | null; + practice?: string | null; + jurisdictions?: string[] | null; + }; + skill_md?: string; columns_config?: { index: number; name: string; prompt: string }[]; - practice?: string | null; }): Promise<Workflow> { return apiRequest<Workflow>("/workflows", { method: "POST", @@ -1150,10 +1209,14 @@ export async function createWorkflow(payload: { export async function updateWorkflow( workflowId: string, payload: { - title?: string; - prompt_md?: string; + metadata?: { + title?: string; + language?: string | null; + practice?: string | null; + jurisdictions?: string[] | null; + }; + skill_md?: string; columns_config?: { index: number; name: string; prompt: string }[]; - practice?: string | null; }, ): Promise<Workflow> { return apiRequest<Workflow>(`/workflows/${workflowId}`, { @@ -1167,6 +1230,23 @@ export async function deleteWorkflow(workflowId: string): Promise<void> { await apiRequest(`/workflows/${workflowId}`, { method: "DELETE" }); } +export async function openSourceWorkflow( + workflowId: string, + payload: { + contributor_mode: OpenSourceWorkflowContributorMode; + contributor?: WorkflowContributor | null; + }, +): Promise<OpenSourceWorkflowResponse> { + return apiRequest<OpenSourceWorkflowResponse>( + `/workflows/${workflowId}/open-source`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); +} + export async function listHiddenWorkflows(): Promise<string[]> { return apiRequest<string[]>("/workflows/hidden"); } diff --git a/frontend/src/lib/supabase.ts b/frontend/src/app/lib/supabase.ts similarity index 100% rename from frontend/src/lib/supabase.ts rename to frontend/src/app/lib/supabase.ts diff --git a/frontend/src/lib/utils.ts b/frontend/src/app/lib/utils.ts similarity index 100% rename from frontend/src/lib/utils.ts rename to frontend/src/app/lib/utils.ts diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 5f4fda3..821400f 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -2,12 +2,12 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { supabase } from "@/lib/supabase"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { supabase } from "@/app/lib/supabase"; +import { Button } from "@/app/components/ui/button"; +import { Input } from "@/app/components/ui/input"; import Link from "next/link"; -import { SiteLogo } from "@/components/site-logo"; -import { useAuth } from "@/contexts/AuthContext"; +import { SiteLogo } from "@/app/components/site-logo"; +import { useAuth } from "@/app/contexts/AuthContext"; const authGlassCardClassName = "rounded-2xl border border-white/70 bg-white/72 p-8 shadow-[0_4px_14px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-8px_18px_rgba(255,255,255,0.12)] backdrop-blur-2xl"; @@ -40,7 +40,7 @@ export default function LoginPage() { setError(null); try { - const { data, error } = await supabase.auth.signInWithPassword({ + const { error } = await supabase.auth.signInWithPassword({ email, password, }); @@ -48,8 +48,12 @@ export default function LoginPage() { if (error) throw error; router.push("/assistant"); - } catch (error: any) { - setError(error.message || "An error occurred during login"); + } catch (error: unknown) { + setError( + error instanceof Error + ? error.message + : "An error occurred during login", + ); } finally { setLoading(false); } diff --git a/frontend/src/app/signup/page.tsx b/frontend/src/app/signup/page.tsx index 36e1f22..9e1351f 100644 --- a/frontend/src/app/signup/page.tsx +++ b/frontend/src/app/signup/page.tsx @@ -2,13 +2,13 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { supabase } from "@/lib/supabase"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { supabase } from "@/app/lib/supabase"; +import { Button } from "@/app/components/ui/button"; +import { Input } from "@/app/components/ui/input"; import Link from "next/link"; -import { SiteLogo } from "@/components/site-logo"; +import { SiteLogo } from "@/app/components/site-logo"; import { CheckCircle2 } from "lucide-react"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/app/contexts/AuthContext"; import { updateUserProfile } from "@/app/lib/mikeApi"; const authGlassCardClassName = diff --git a/frontend/src/app/support/page.tsx b/frontend/src/app/support/page.tsx index 9247ef6..08b08d4 100644 --- a/frontend/src/app/support/page.tsx +++ b/frontend/src/app/support/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { Send, CheckCircle } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useAuth } from "@/contexts/AuthContext"; +import { useAuth } from "@/app/contexts/AuthContext"; type FeedbackType = "bug" | "feature" | "question" | "other"; diff --git a/frontend/src/app/types/luckyexcel.d.ts b/frontend/src/app/types/luckyexcel.d.ts new file mode 100644 index 0000000..b64468f --- /dev/null +++ b/frontend/src/app/types/luckyexcel.d.ts @@ -0,0 +1,30 @@ +declare module "luckyexcel" { + export interface LuckyExcelSheet { + name: string; + celldata?: unknown[]; + index?: string; + order?: number; + [key: string]: unknown; + } + + export interface LuckyExcelJson { + sheets: LuckyExcelSheet[]; + info?: { name?: string; creator?: string }; + } + + type TransformCallback = ( + exportJson: LuckyExcelJson, + luckysheetfile: string, + ) => void; + + const LuckyExcel: { + transformExcelToLucky(file: File | Blob, callback: TransformCallback): void; + transformExcelToLuckyByUrl( + url: string, + name: string, + callback: TransformCallback, + ): void; + }; + + export default LuckyExcel; +} diff --git a/frontend/src/app/verify-mfa/page.tsx b/frontend/src/app/verify-mfa/page.tsx index 5fdb600..f5a3494 100644 --- a/frontend/src/app/verify-mfa/page.tsx +++ b/frontend/src/app/verify-mfa/page.tsx @@ -3,14 +3,14 @@ import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Loader2 } from "lucide-react"; -import { SiteLogo } from "@/components/site-logo"; -import { Button } from "@/components/ui/button"; -import { useAuth } from "@/contexts/AuthContext"; -import { supabase } from "@/lib/supabase"; +import { SiteLogo } from "@/app/components/site-logo"; +import { Button } from "@/app/components/ui/button"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { supabase } from "@/app/lib/supabase"; import { needsMfaVerification, VerificationCodeInput, -} from "@/app/components/shared/MfaVerificationPopup"; +} from "@/app/components/popups/MfaVerificationPopup"; import { markMfaVerifiedForGate } from "@/app/components/shared/MfaLoginGate"; type MfaFactor = { diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx deleted file mode 100644 index fd3a406..0000000 --- a/frontend/src/components/ui/badge.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", - secondary: - "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", - destructive: - "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Badge({ - className, - variant, - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps<typeof badgeVariants> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" - - return ( - <Comp - data-slot="badge" - className={cn(badgeVariants({ variant }), className)} - {...props} - /> - ) -} - -export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/text-search-widget.tsx b/frontend/src/components/ui/text-search-widget.tsx deleted file mode 100644 index 4740f45..0000000 --- a/frontend/src/components/ui/text-search-widget.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Input } from "@/components/ui/input"; -import { ArrowUp, ArrowDown, X } from "lucide-react"; -import { useRef } from "react"; - -interface TextSearchWidgetProps { - isOpen: boolean; - onClose: () => void; - searchQuery: string; - onSearchChange: (query: string) => void; - currentMatchIdx: number; - matchCount: number; - setCurrentMatchIdx: (idx: number | ((prev: number) => number)) => void; - className?: string; -} - -export function TextSearchWidget({ - isOpen, - onClose, - searchQuery, - onSearchChange, - currentMatchIdx, - matchCount, - setCurrentMatchIdx, - className = "", -}: TextSearchWidgetProps) { - const searchInputRef = useRef<HTMLInputElement>(null); - - const handleNext = () => { - if (matchCount === 0) return; - setCurrentMatchIdx((prev) => (prev + 1) % matchCount); - }; - - const handlePrev = () => { - if (matchCount === 0) return; - setCurrentMatchIdx((prev) => (prev - 1 + matchCount) % matchCount); - }; - - if (!isOpen) return null; - - return ( - <div - className={`flex flex-col bg-white shadow-lg border border-gray-200 rounded-md overflow-hidden min-w-[300px] ${className}`} - > - <div className="flex items-center gap-1 p-1"> - <div className="flex-1 relative"> - <Input - ref={searchInputRef} - autoFocus - placeholder="Find" - value={searchQuery} - onChange={(e) => onSearchChange(e.target.value)} - className="h-8 text-sm w-full pr-[80px] rounded-sm border-gray-200 bg-gray-100/50 focus-visible:ring-0 focus-visible:border-blue-600 placeholder:text-gray-500" - onKeyDown={(e) => { - if (e.key === "Escape") { - onClose(); - onSearchChange(""); - } else if (e.key === "Enter") { - if (e.shiftKey) handlePrev(); - else handleNext(); - } - }} - /> - </div> - </div> - - {/* Results count and navigation */} - {searchQuery && ( - <div className="flex items-center justify-between px-2 pb-1 pt-0.5 text-xs text-gray-500"> - <span> - {matchCount > 0 - ? `${currentMatchIdx + 1} of ${matchCount}` - : "No results"} - </span> - <div className="flex items-center gap-1"> - <button - onClick={handlePrev} - disabled={matchCount === 0} - className="p-1 hover:bg-gray-100 rounded disabled:opacity-50" - > - <ArrowUp className="h-4 w-4" /> - </button> - <button - onClick={handleNext} - disabled={matchCount === 0} - className="p-1 hover:bg-gray-100 rounded disabled:opacity-50" - > - <ArrowDown className="h-4 w-4" /> - </button> - </div> - </div> - )} - </div> - ); -} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts deleted file mode 100644 index 6199490..0000000 --- a/frontend/src/lib/auth.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NextRequest } from 'next/server'; - -/** - * Extract and validate user from Supabase JWT token - * Returns user info if valid, null if invalid or missing - * - * @param request NextRequest with Authorization header - * @returns User object with email and id, or null - */ -export async function getUserFromRequest(request: NextRequest): Promise<{ - email: string; - id: string; -} | null> { - try { - const authHeader = request.headers.get('Authorization'); - - if (!authHeader?.startsWith('Bearer ')) { - return null; - } - - const token = authHeader.substring(7); - - if (!token) { - return null; - } - - // Validate with Supabase - const { createClient } = await import('@supabase/supabase-js'); - const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY! - ); - - const { data: { user }, error } = await supabase.auth.getUser(token); - - if (error || !user) { - console.warn('[Auth] Invalid or expired token:', error?.message); - return null; - } - - if (!user.email) { - console.warn('[Auth] User has no email'); - return null; - } - - return { - email: user.email, - id: user.id - }; - } catch (error) { - console.error('[Auth] Error validating token:', error); - return null; - } -} diff --git a/frontend/src/lib/fileConverter.ts b/frontend/src/lib/fileConverter.ts deleted file mode 100644 index 6644133..0000000 --- a/frontend/src/lib/fileConverter.ts +++ /dev/null @@ -1,65 +0,0 @@ -import mammoth from "mammoth"; - -// Helper function to convert File to base64 data URL -const fileToBase64 = (file: File): Promise<string> => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const result = reader.result as string; - resolve(result); - }; - reader.onerror = (error) => reject(error); - }); -}; - -export async function processFiles( - files: { name: string; file?: File }[] -): Promise<{ filename: string; data: string }[]> { - const processedFilesPromises = files - .filter((f) => f.file) - .map(async (f) => { - const file = f.file!; - let processedFile: File = file; - - // Apply conversions if needed - if ( - file.type === - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || - file.name.endsWith(".docx") - ) { - processedFile = await convertDocxToText(file); - } else if ( - file.type === "text/plain" || - file.name.endsWith(".txt") || - file.name.endsWith(".doc") - ) { - processedFile = await convertToTextFile(file); - } - // PDFs and others are left as-is - - return { - filename: processedFile.name, - data: await fileToBase64(processedFile), - }; - }); - - return Promise.all(processedFilesPromises); -} - -async function convertDocxToText(file: File): Promise<File> { - const arrayBuffer = await file.arrayBuffer(); - const result = await mammoth.extractRawText({ arrayBuffer }); - const text = result.value; - return createTextFile(text, file.name); -} - -async function convertToTextFile(file: File): Promise<File> { - const text = await file.text(); - return createTextFile(text, file.name); -} - -function createTextFile(content: string, fileName: string): File { - const blob = new Blob([content], { type: "text/plain" }); - return new File([blob], fileName, { type: "text/plain" }); -} diff --git a/frontend/src/lib/label.ts b/frontend/src/lib/label.ts deleted file mode 100644 index e46cc23..0000000 --- a/frontend/src/lib/label.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Compute a display label from node components. - * This mirrors the backend format_lineage_label function. - */ - -const STRUCTURE_NODE_TYPES = [ - "subtitle", - "division", - "chapter", - "subchapter", - "part", - "subpart", - "article", - "subarticle", - "appendix", -] as const; - -type StructureNodeType = (typeof STRUCTURE_NODE_TYPES)[number]; -type CodeType = "title" | "section" | StructureNodeType | string; - -export interface NodeComponents { - code_type: CodeType; - identifier: string; -} - -export function computeLabel({ code_type, identifier }: NodeComponents): string { - return `${code_type} ${identifier}`; -} diff --git a/frontend/src/lib/slug.ts b/frontend/src/lib/slug.ts deleted file mode 100644 index bf9a47f..0000000 --- a/frontend/src/lib/slug.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** Encode a tree slug containing `/` so it can live inside a Next.js dynamic segment. */ -export function encodeNodeSlug(slug: string): string { - if (!slug) { - return ""; - } - return slug.replace(/\//g, "~"); -} - -/** Decode the slug from the route segment back into its canonical form. */ -export function decodeNodeSlug(segment: string): string { - return segment.replace(/~/g, "/"); -} - -/** - * Convert a backend slug to a frontend URL path. - * Backend slugs match frontend URLs exactly now. - * Examples: - * "usc/title-49" -> "/usc/title-49" - * "usc/title-49/subtitle-IV/part-B/chapter-145" -> "/usc/title-49/subtitle-IV/part-B/chapter-145" - * "usc/title-49/14501" -> "/usc/title-49/14501" - */ -const UNICODE_DASH_REGEX = - /[\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D]/g; - -function normalizeSlugDashes(value: string): string { - return value.replace(UNICODE_DASH_REGEX, "-"); -} - -export function slugToPath(slug: string): string { - if (!slug) { - return ""; - } - const normalized = normalizeSlugDashes(slug); - - // Handle CFR slugs: "cfr/title-1/..." -> "/sources/cfr/title-1/..." - if (normalized.startsWith("cfr/")) { - const cleanSlug = normalized.replace(/^cfr\//, ""); - return `/sources/cfr/${cleanSlug}`; - } - - // Handle USC slugs: "usc/title-05" -> "/sources/usc/title-05" - const cleanSlug = normalized.replace(/^usc\//, ""); - return `/sources/usc/${cleanSlug}`; -} - -/** - * Build a section URL from title and section numbers. - * Format: "/sources/usc/title-{number}/{section_number}" - */ -export function buildSectionUrl( - titleNumber: number, - sectionNumber: string, -): string { - const paddedTitle = titleNumber.toString().padStart(2, "0"); - const normalizedSection = normalizeSlugDashes(sectionNumber); - return `/sources/usc/title-${paddedTitle}/${normalizedSection}`; -} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts deleted file mode 100644 index 1983c75..0000000 --- a/frontend/src/lib/types.ts +++ /dev/null @@ -1,81 +0,0 @@ -export interface TitleSummary { - title_number: number; - heading: string; - citation: string; - slug: string; -} - -export interface SectionNeighbor { - slug: string; - citation?: string | null; - heading?: string | null; - title_number: number; - section_number?: string | null; -} - -export interface ChapterNavigation { - label: string; - slug: string; - sections: SectionNeighbor[]; -} - -export interface CrossReference { - to_citation: string; - resolved: boolean; - to_slug?: string | null; - context_snippet?: string | null; -} - -export interface SectionNote { - title: string; - html: string; -} - -export interface BreadcrumbNode { - id: string; - slug: string; - code_type: string; - identifier?: string | null; - heading?: string | null; -} - -export interface SectionDetail { - id: string; - slug: string; - citation: string; - heading?: string | null; - title_number: number; - section_number: string; - path: BreadcrumbNode[]; - text_html: string; - text_plain?: string | null; - text_markdown?: string | null; - effective_start: string; - effective_end?: string | null; - source_document?: string | null; - source_note?: string | null; - neighbors: { - previous?: SectionNeighbor | null; - next?: SectionNeighbor | null; - }; - chapter_navigation?: ChapterNavigation | null; - cross_references: CrossReference[]; - notes: SectionNote[]; - chapter_number?: string | null; -} - -export interface SearchResultItem { - slug: string; - citation: string; - heading?: string | null; - title_number: number; - section_number: string; - snippet?: string | null; -} - -export interface SearchResponse { - total: number; - limit: number; - offset: number; - results: SearchResultItem[]; -} diff --git a/frontend/src/scripts/convert-courts-to-ts.js b/frontend/src/scripts/convert-courts-to-ts.js deleted file mode 100644 index 6f469e2..0000000 --- a/frontend/src/scripts/convert-courts-to-ts.js +++ /dev/null @@ -1,89 +0,0 @@ -const fs = require("fs"); -const path = require("path"); - -// Read the JSON file with comments -const jsonPath = path.join(__dirname, "../hooks/google-scholar-courts.json"); -const content = fs.readFileSync(jsonPath, "utf8"); - -// Remove comments from JSON (simple approach - remove lines starting with //) -const lines = content.split("\n"); -const cleanedLines = lines.filter((line) => { - const trimmed = line.trim(); - return !trimmed.startsWith("//"); -}); - -// Also remove trailing commas before closing braces/brackets (common JSON issue) -let cleanedContent = cleanedLines.join("\n"); -cleanedContent = cleanedContent.replace(/,(\s*[}\]])/g, "$1"); - -// Parse the cleaned JSON -const data = JSON.parse(cleanedContent); - -// Generate TypeScript file content -let tsContent = `// Court data types and constants -// This file is auto-generated from google-scholar-courts.json - -export interface SubCourt { - id: string; - value: string; - label: string; -} - -export interface StateCourt { - id: string; - value: string; - label: string; - subCourts: SubCourt[]; -} - -export interface Circuit { - id: string; - value: string; - label: string; - subCourts: SubCourt[]; -} - -export interface FederalCourts { - supreme: SubCourt[]; - specialist: SubCourt[]; - circuits: Circuit[]; -} - -export interface CourtsData { - stateCourts: StateCourt[]; - federalCourts: FederalCourts; -} - -// Court data -export const courtsData: CourtsData = ${JSON.stringify(data, null, 4)}; - -export const stateCourtsData = courtsData.stateCourts; -export const federalCourtsData = courtsData.federalCourts; - -// Helper functions -export function getCircuits(): Circuit[] { - return federalCourtsData.circuits; -} - -export function getCircuitCourts(circuitLabel: string): SubCourt[] { - const circuit = federalCourtsData.circuits.find( - (c) => c.label === circuitLabel - ); - return circuit?.subCourts || []; -} - -export function getNationalCourts(): SubCourt[] { - return federalCourtsData.supreme; -} - -export function getSpecialistCourts(): SubCourt[] { - return federalCourtsData.specialist || []; -} -`; - -// Write to court-data.ts -const outputPath = path.join(__dirname, "../data/court-data.ts"); -fs.writeFileSync(outputPath, tsContent, "utf8"); - -console.log("Successfully converted court data to TypeScript!"); -console.log(`Output: ${outputPath}`); diff --git a/scripts/build-workflows.js b/scripts/build-workflows.js new file mode 100644 index 0000000..f4fc14c --- /dev/null +++ b/scripts/build-workflows.js @@ -0,0 +1,504 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); + +const ROOT_DIR = path.resolve(__dirname, ".."); +const WORKSPACE_DIR = path.resolve(ROOT_DIR, ".."); +const WORKFLOWS_DIR = path.join(WORKSPACE_DIR, "mike-workflows"); +const SYSTEM_WORKFLOWS_DIR = path.join(WORKFLOWS_DIR, "system"); +const BACKEND_OUT = path.join(ROOT_DIR, "backend/src/lib/systemWorkflows.ts"); +const LANDING_OUT = path.join(ROOT_DIR, "landing/app/generated-workflows.ts"); + +function readText(filePath) { + return fs.readFileSync(filePath, "utf8"); +} + +function readJson(filePath) { + try { + return JSON.parse(readText(filePath)); + } catch (error) { + throw new Error(`${relative(filePath)} is not valid JSON: ${error.message}`); + } +} + +function relative(filePath) { + return path.relative(WORKSPACE_DIR, filePath); +} + +function fail(message) { + throw new Error(message); +} + +function parseScalar(value, label) { + const trimmed = value.trim(); + if (trimmed === "null") return null; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (/^-?\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + try { + return JSON.parse(trimmed); + } catch (error) { + fail(`${label} is not valid inline JSON: ${error.message}`); + } + } + return trimmed; +} + +function parseSimpleYaml(source, label) { + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const result = {}; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim() || line.trim().startsWith("#")) continue; + if (line.startsWith(" ")) { + fail(`${label}:${i + 1} has unsupported indentation`); + } + + const match = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/); + if (!match) fail(`${label}:${i + 1} is not valid frontmatter`); + const key = match[1]; + const rawValue = match[2].trim(); + + if (rawValue) { + result[key] = parseScalar(rawValue, `${label}.${key}`); + continue; + } + + const scalarItems = []; + const objectItems = []; + let mode = null; + i++; + for (; i < lines.length; i++) { + const child = lines[i]; + if (!child.trim()) continue; + if (!child.startsWith(" ")) { + i--; + break; + } + + const listMatch = child.match(/^ -(?:\s+(.*))?$/); + if (listMatch) { + const itemText = listMatch[1]?.trim() ?? ""; + if (itemText.includes(":")) { + mode ??= "objects"; + if (mode !== "objects") { + fail(`${label}.${key} mixes scalar and object list items`); + } + const object = {}; + if (itemText) { + const itemMatch = itemText.match(/^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/); + if (!itemMatch) { + fail(`${label}:${i + 1} is not a valid object list item`); + } + object[itemMatch[1]] = parseScalar( + itemMatch[2], + `${label}.${key}.${itemMatch[1]}`, + ); + } + objectItems.push(object); + continue; + } + + mode ??= "scalars"; + if (mode !== "scalars") { + fail(`${label}.${key} mixes object and scalar list items`); + } + scalarItems.push(parseScalar(itemText, `${label}.${key}`)); + continue; + } + + const propMatch = child.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/); + if (!propMatch || mode !== "objects" || objectItems.length === 0) { + fail(`${label}:${i + 1} has unsupported frontmatter structure`); + } + objectItems[objectItems.length - 1][propMatch[1]] = parseScalar( + propMatch[2], + `${label}.${key}.${propMatch[1]}`, + ); + } + + result[key] = mode === "objects" ? objectItems : scalarItems; + } + + return result; +} + +function readSkillFile(filePath) { + const text = readText(filePath).replace(/\r\n/g, "\n"); + if (!text.startsWith("---\n")) { + fail(`${relative(filePath)} must start with YAML frontmatter`); + } + const close = text.indexOf("\n---", 4); + if (close === -1) { + fail(`${relative(filePath)} is missing closing YAML frontmatter marker`); + } + const afterClose = text.slice(close + 4); + if (afterClose && !afterClose.startsWith("\n")) { + fail(`${relative(filePath)} has invalid frontmatter closing marker`); + } + return { + metadata: parseSimpleYaml(text.slice(4, close), relative(filePath)), + body: afterClose.replace(/^\n/, "").trimEnd(), + fullText: text.trimEnd(), + }; +} + +function parseTableConfigYaml(filePath) { + const lines = readText(filePath).replace(/\r\n/g, "\n").split("\n"); + const result = { columns_config: [] }; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + if (!line.trim() || line.trim().startsWith("#")) { + i++; + continue; + } + + const schemaMatch = line.match(/^\$schema:\s*(.+)$/); + if (schemaMatch) { + result.$schema = parseScalar(schemaMatch[1], `${relative(filePath)}.$schema`); + i++; + continue; + } + + if (line !== "columns_config:") { + fail(`${relative(filePath)}:${i + 1} is not valid table config YAML`); + } + i++; + break; + } + + let current = null; + for (; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) continue; + + const itemMatch = line.match(/^ - index:\s*(.+)$/); + if (itemMatch) { + current = { + index: parseScalar(itemMatch[1], `${relative(filePath)}.columns_config.index`), + }; + result.columns_config.push(current); + continue; + } + + if (!current) { + fail(`${relative(filePath)}:${i + 1} column entry must start with index`); + } + + const propMatch = line.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/); + if (!propMatch) { + fail(`${relative(filePath)}:${i + 1} is not a valid column property`); + } + const key = propMatch[1]; + const rawValue = propMatch[2].trim(); + + if (key === "tags" && rawValue === "") { + const tags = []; + i++; + for (; i < lines.length; i++) { + const tagMatch = lines[i].match(/^ -\s*(.+)$/); + if (!tagMatch) { + i--; + break; + } + tags.push(parseScalar(tagMatch[1], `${relative(filePath)}.${key}`)); + } + current.tags = tags; + continue; + } + + if (rawValue === ">-" || rawValue === ">" || rawValue === "|-" || rawValue === "|") { + const parts = []; + i++; + for (; i < lines.length; i++) { + if (!lines[i].startsWith(" ")) { + i--; + break; + } + parts.push(lines[i].slice(6)); + } + current[key] = rawValue.startsWith("|") + ? parts.join("\n") + : parts.join(" ").replace(/\s+/g, " ").trim(); + continue; + } + + current[key] = parseScalar(rawValue, `${relative(filePath)}.${key}`); + } + + return result; +} + +function assertString(value, label) { + if (typeof value !== "string" || value.trim() === "") { + fail(`${label} must be a non-empty string`); + } +} + +function assertOptionalString(value, label) { + if (value === undefined || value === null) return; + if (typeof value !== "string") fail(`${label} must be a string`); +} + +function assertOptionalStringArray(value, label) { + if (value === undefined || value === null) return; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + fail(`${label} must be an array of strings`); + } +} + +function normalizeContributors(value, label) { + if (!Array.isArray(value) || value.length === 0) { + fail(`${label} must be a non-empty array`); + } + return value.map((contributor, index) => { + const contributorLabel = `${label}[${index}]`; + if (!contributor || typeof contributor !== "object" || Array.isArray(contributor)) { + fail(`${contributorLabel} must be an object`); + } + assertString(contributor.name, `${contributorLabel}.name`); + assertOptionalString(contributor.organisation, `${contributorLabel}.organisation`); + assertOptionalString(contributor.role, `${contributorLabel}.role`); + assertOptionalString(contributor.linkedin, `${contributorLabel}.linkedin`); + return { + name: contributor.name.trim(), + organisation: contributor.organisation?.trim() || null, + role: contributor.role?.trim() || null, + linkedin: contributor.linkedin?.trim() || null, + }; + }); +} + +function assertColumnConfig(columns, label) { + if (!Array.isArray(columns) || columns.length === 0) { + fail(`${label}.columns_config must be a non-empty array`); + } + + columns.forEach((column, index) => { + const columnLabel = `${label}.columns_config[${index}]`; + if (!column || typeof column !== "object" || Array.isArray(column)) { + fail(`${columnLabel} must be an object`); + } + if (!Number.isInteger(column.index)) { + fail(`${columnLabel}.index must be an integer`); + } + assertString(column.name, `${columnLabel}.name`); + assertString(column.prompt, `${columnLabel}.prompt`); + assertOptionalString(column.format, `${columnLabel}.format`); + assertOptionalStringArray(column.tags, `${columnLabel}.tags`); + }); +} + +function readWorkflow(category, dirent) { + const slug = dirent.name; + const workflowDir = path.join(SYSTEM_WORKFLOWS_DIR, category, slug); + const metadataPath = path.join(workflowDir, "metadata.json"); + if (fs.existsSync(metadataPath)) { + fail(`${relative(metadataPath)} is no longer supported; use SKILL.md frontmatter`); + } + const skillPath = path.join(workflowDir, "SKILL.md"); + if (!fs.existsSync(skillPath)) { + fail(`${relative(skillPath)} is required`); + } + const { metadata, body: skillMd, fullText: sourceSkillMd } = readSkillFile(skillPath); + const label = `${relative(skillPath)} frontmatter`; + const id = `builtin-${slug}`; + + if (metadata.id !== undefined) { + fail(`${label}.id is not supported; the ID is generated from the directory name`); + } + if (metadata.$schema !== undefined) { + fail(`${label}.$schema is not supported in SKILL.md frontmatter`); + } + if (metadata.title !== undefined) { + fail(`${label}.title is not supported; use name`); + } + if (metadata.order !== undefined) { + fail(`${label}.order is not supported`); + } + assertString(metadata.name, `${label}.name`); + if (metadata.name !== slug) { + fail(`${label}.name must match the folder name "${slug}"`); + } + assertString(metadata.display_name, `${label}.display_name`); + assertString(metadata.description, `${label}.description`); + const contributors = normalizeContributors( + metadata.contributors, + `${label}.contributors`, + ); + assertString(metadata.language, `${label}.language`); + assertString(metadata.version, `${label}.version`); + if (metadata.type !== category) { + fail(`${label}.type must be "${category}"`); + } + if (metadata.category !== undefined) { + fail(`${label}.category is not supported`); + } + if (metadata.action !== undefined) { + fail(`${label}.action is not supported`); + } + assertOptionalString(metadata.practice, `${label}.practice`); + assertOptionalStringArray(metadata.jurisdictions, `${label}.jurisdictions`); + + if (category === "assistant") { + if (!skillMd.trim()) { + fail(`${relative(skillPath)} must include instructions after frontmatter`); + } + const tableConfigPath = path.join(workflowDir, "table-config.yaml"); + if (fs.existsSync(tableConfigPath)) { + fail(`${relative(tableConfigPath)} is only supported for tabular workflows`); + } + return { + id, + metadata: { + title: metadata.display_name, + description: metadata.description, + type: metadata.type, + contributors, + language: metadata.language, + version: metadata.version, + practice: metadata.practice ?? null, + jurisdictions: metadata.jurisdictions ?? null, + }, + skill_md: skillMd, + source_skill_md: sourceSkillMd, + columns_config: null, + }; + } + + if (metadata.columns_config !== undefined) { + fail(`${label}.columns_config is not supported; use table-config.yaml`); + } + const legacyTableConfigPath = path.join(workflowDir, "table-config.json"); + if (fs.existsSync(legacyTableConfigPath)) { + fail(`${relative(legacyTableConfigPath)} is no longer supported; use table-config.yaml`); + } + const tableConfigPath = path.join(workflowDir, "table-config.yaml"); + if (!fs.existsSync(tableConfigPath)) { + fail(`${relative(tableConfigPath)} is required for tabular workflows`); + } + const tableConfig = parseTableConfigYaml(tableConfigPath); + const tableConfigLabel = relative(tableConfigPath); + const expectedTableConfigSchema = "../../../schema/table-config.schema.yaml"; + if (tableConfig.$schema !== expectedTableConfigSchema) { + fail(`${tableConfigLabel}.$schema must be "${expectedTableConfigSchema}"`); + } + assertColumnConfig(tableConfig.columns_config, tableConfigLabel); + + return { + id, + metadata: { + title: metadata.display_name, + description: metadata.description, + type: metadata.type, + contributors, + language: metadata.language, + version: metadata.version, + practice: metadata.practice ?? null, + jurisdictions: metadata.jurisdictions ?? null, + }, + skill_md: skillMd || null, + source_skill_md: sourceSkillMd, + columns_config: tableConfig.columns_config, + }; +} + +function loadWorkflows() { + const workflows = []; + const seenIds = new Set(); + + for (const category of ["assistant", "tabular"]) { + const categoryDir = path.join(SYSTEM_WORKFLOWS_DIR, category); + if (!fs.existsSync(categoryDir)) continue; + + const entries = fs + .readdirSync(categoryDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith(".")) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const workflow = readWorkflow(category, entry); + if (seenIds.has(workflow.id)) { + fail(`Duplicate workflow id: ${workflow.id}`); + } + seenIds.add(workflow.id); + workflows.push(workflow); + } + } + + return workflows.sort((a, b) => a.id.localeCompare(b.id)); +} + +function formatTs(value) { + return JSON.stringify(value, null, 4); +} + +function writeGeneratedFiles(workflows) { + const systemWorkflows = workflows.map((workflow) => ({ + user_id: null, + is_system: true, + created_at: "", + id: workflow.id, + metadata: workflow.metadata, + skill_md: workflow.skill_md, + columns_config: workflow.columns_config, + })); + const systemAssistantWorkflows = workflows + .filter((workflow) => workflow.metadata.type === "assistant") + .map((workflow) => ({ + id: workflow.id, + title: workflow.metadata.title, + skill_md: workflow.skill_md, + })); + const landingWorkflows = workflows.map((workflow) => ({ + id: workflow.id, + metadata: workflow.metadata, + skill_md: workflow.source_skill_md, + columnCount: workflow.columns_config?.length ?? 0, + columns: workflow.columns_config ?? [], + })); + + const backendText = `// This file is generated by scripts/build-workflows.js. Do not edit it directly.\n\nexport type SystemWorkflowContributor = {\n name: string;\n organisation: string | null;\n role: string | null;\n linkedin: string | null;\n};\n\nexport type SystemWorkflowMetadata = {\n title: string;\n description: string;\n type: "assistant" | "tabular";\n contributors: SystemWorkflowContributor[];\n language: string;\n version: string;\n practice: string | null;\n jurisdictions: string[] | null;\n};\n\nexport type SystemWorkflow = {\n id: string;\n user_id: null;\n is_system: true;\n created_at: string;\n metadata: SystemWorkflowMetadata;\n skill_md: string | null;\n columns_config: { index: number; name: string; format?: string; prompt: string; tags?: string[] }[] | null;\n};\n\nexport const SYSTEM_WORKFLOWS: SystemWorkflow[] = ${formatTs(systemWorkflows)};\n\nexport const SYSTEM_WORKFLOW_IDS = new Set(SYSTEM_WORKFLOWS.map((wf) => wf.id));\n\nexport const SYSTEM_ASSISTANT_WORKFLOWS: { id: string; title: string; skill_md: string }[] = ${formatTs(systemAssistantWorkflows)};\n`; + + const landingText = `// This file is generated by scripts/build-workflows.js. Do not edit it directly.\nimport type { LandingWorkflow } from "./workflow-browser";\n\nexport const LANDING_WORKFLOWS: LandingWorkflow[] = ${formatTs(landingWorkflows)};\n`; + + fs.writeFileSync(BACKEND_OUT, backendText); + if (fs.existsSync(path.dirname(LANDING_OUT))) { + fs.writeFileSync(LANDING_OUT, landingText); + } +} + +function main() { + if (!fs.existsSync(WORKFLOWS_DIR)) { + fail(`Workflow source directory not found: ${relative(WORKFLOWS_DIR)}`); + } + if (!fs.existsSync(SYSTEM_WORKFLOWS_DIR)) { + fail(`System workflow source directory not found: ${relative(SYSTEM_WORKFLOWS_DIR)}`); + } + + const workflows = loadWorkflows(); + if (workflows.length === 0) { + fail("No workflows found"); + } + + writeGeneratedFiles(workflows); + console.log(`Generated ${workflows.length} system workflows.`); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +}