Merge pull request #206 from Open-Legal-Products/workflows-ui-excel-ppt-updates

Workflows UI excel ppt updates
This commit is contained in:
cosimoastrada 2026-07-08 18:30:54 +08:00 committed by GitHub
commit e32daad5a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
207 changed files with 21941 additions and 14812 deletions

View file

@ -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.

View file

@ -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=="],
}
}

View file

@ -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)
),

View file

@ -0,0 +1,120 @@
-- Migration date: 2026-06-25
-- Custom workflow metadata fields and workflow overview read model. System
-- workflow versions remain generated from the repository metadata and are not
-- stored on user workflow rows.
drop function if exists public.get_workflows_overview(text, text, text);
alter table public.workflows
drop column if exists author,
drop column if exists category,
drop column if exists is_system,
add column if not exists language text default 'English',
add column if not exists jurisdictions text[] default array['General']::text[];
alter table public.workflows
alter column language set default 'English',
alter column practice set default 'General Transactions',
alter column jurisdictions set default array['General']::text[];
update public.workflows
set
language = coalesce(nullif(trim(language), ''), 'English'),
practice = coalesce(nullif(trim(practice), ''), 'General Transactions'),
jurisdictions = coalesce(jurisdictions, array['General']::text[])
where user_id is not null;
create or replace function public.get_workflows_overview(
p_user_id text,
p_user_email text default null,
p_type text default null
)
returns table (
id uuid,
user_id text,
title text,
type text,
prompt_md text,
columns_config jsonb,
language text,
practice text,
jurisdictions text[],
is_system boolean,
created_at timestamptz,
allow_edit boolean,
is_owner boolean,
shared_by_name text
)
language sql
stable
as $$
with owned as (
select
w.id,
w.user_id::text as user_id,
w.title,
w.type,
w.prompt_md,
w.columns_config,
w.language,
w.practice,
w.jurisdictions,
false as is_system,
w.created_at,
true as allow_edit,
true as is_owner,
null::text as shared_by_name,
0 as sort_bucket
from public.workflows w
where w.user_id::text = p_user_id
and (p_type is null or w.type = p_type)
),
shared as (
select
w.id,
w.user_id::text as user_id,
w.title,
w.type,
w.prompt_md,
w.columns_config,
w.language,
w.practice,
w.jurisdictions,
false as is_system,
w.created_at,
ws.allow_edit,
false as is_owner,
nullif(trim(up.display_name), '') as shared_by_name,
1 as sort_bucket
from public.workflow_shares ws
join public.workflows w
on w.id = ws.workflow_id
left join public.user_profiles up
on up.user_id::text = ws.shared_by_user_id::text
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
and (p_type is null or w.type = p_type)
),
visible_workflows as (
select * from owned
union all
select * from shared
)
select
vw.id,
vw.user_id,
vw.title,
vw.type,
vw.prompt_md,
vw.columns_config,
vw.language,
vw.practice,
vw.jurisdictions,
vw.is_system,
vw.created_at,
vw.allow_edit,
vw.is_owner,
vw.shared_by_name
from visible_workflows vw
order by vw.sort_bucket asc, vw.created_at desc;
$$;

View file

@ -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;

View file

@ -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;
$$;

View file

@ -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;
$$;

View file

@ -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 $$;

View file

@ -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",

View file

@ -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": {

View file

@ -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,

View file

@ -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.",
},
];

View file

@ -0,0 +1,310 @@
import { type DocIndex, resolveDoc } from "./types";
// ---------------------------------------------------------------------------
// Internal citation parse types
// ---------------------------------------------------------------------------
type DocumentQuote = {
page: number | string;
quote: string;
// Spreadsheet sources are located by cell instead of page: `sheet` is the
// worksheet name and `cell` is an A1 address or range (e.g. "B7" or "B7:C9").
sheet?: string;
cell?: string;
};
type ParsedDocumentCitation = {
kind: "document";
ref: number;
doc_id: string;
page: number | string;
quote: string;
sheet?: string;
cell?: string;
quotes: DocumentQuote[];
};
type ParsedCaseCitation = {
kind: "case";
ref: number;
cluster_id: number;
quotes: {
opinionId: number | null;
type: string | null;
author: string | null;
quote: string;
}[];
};
type ParsedCitation = ParsedDocumentCitation | ParsedCaseCitation;
function normalizeCitation(raw: unknown): ParsedCitation | null {
if (!raw || typeof raw !== "object") return null;
const c = raw as Record<string, unknown>;
const markerRef =
typeof c.marker === "string"
? Number(c.marker.match(/^\[(\d+)\]$/)?.[1])
: NaN;
const ref =
typeof c.ref === "number"
? c.ref
: Number.isFinite(markerRef)
? markerRef
: null;
if (typeof ref !== "number") return null;
const quote = typeof c.quote === "string" ? c.quote : c.text;
const rawClusterId =
typeof c.cluster_id === "number"
? c.cluster_id
: typeof c.clusterId === "number"
? c.clusterId
: typeof c.cluster_id === "string"
? Number.parseInt(c.cluster_id, 10)
: typeof c.clusterId === "string"
? Number.parseInt(c.clusterId, 10)
: NaN;
if (Number.isFinite(rawClusterId) && rawClusterId > 0) {
const quotes = normalizeCaseCitationQuotes(c);
if (!quotes.length) {
if (typeof quote !== "string" || !quote) return null;
quotes.push({ opinionId: null, type: null, author: null, quote });
}
return { kind: "case", ref, cluster_id: Math.floor(rawClusterId), quotes };
}
if (typeof c.doc_id !== "string") return null;
const quotes = normalizeDocumentCitationQuotes(c);
if (!quotes.length) {
if (typeof quote !== "string" || !quote) return null;
quotes.push({
page: normalizeCitationPage(c.page),
quote,
...normalizeCellLocator(c),
});
}
return {
kind: "document",
ref,
doc_id: c.doc_id,
page: quotes[0].page,
quote: quotes[0].quote,
sheet: quotes[0].sheet,
cell: quotes[0].cell,
quotes,
};
}
/** Pull an optional spreadsheet `{sheet, cell}` locator off a raw object. */
function normalizeCellLocator(
c: Record<string, unknown>,
): { sheet?: string; cell?: string } {
const out: { sheet?: string; cell?: string } = {};
if (typeof c.sheet === "string" && c.sheet.trim()) out.sheet = c.sheet.trim();
if (typeof c.cell === "string" && c.cell.trim()) out.cell = c.cell.trim();
return out;
}
function normalizeCitationPage(value: unknown): number | string {
if (typeof value === "number") {
return value;
} else if (typeof value === "string" && /^\d+\s*-\s*\d+$/.test(value)) {
return value;
} else {
const n = parseInt(String(value ?? ""), 10);
if (!Number.isFinite(n)) return 1;
return n;
}
}
function normalizeDocumentCitationQuotes(
c: Record<string, unknown>,
): DocumentQuote[] {
if (!Array.isArray(c.quotes)) return [];
return c.quotes
.slice(0, 3)
.map((raw): DocumentQuote | null => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const text = typeof row.quote === "string" ? row.quote : row.text;
if (typeof text !== "string" || !text.trim()) return null;
// Fall back to the top-level sheet/cell so a citation can set them once.
return {
page: normalizeCitationPage(row.page ?? c.page),
quote: text,
...normalizeCellLocator({
sheet: row.sheet ?? c.sheet,
cell: row.cell ?? c.cell,
}),
};
})
.filter((quote): quote is DocumentQuote => !!quote);
}
function normalizeCaseCitationQuotes(c: Record<string, unknown>) {
if (!Array.isArray(c.quotes)) return [];
return c.quotes
.slice(0, 3)
.map((raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const text = typeof row.quote === "string" ? row.quote : row.text;
if (typeof text !== "string" || !text.trim()) return null;
const opinionId =
typeof row.opinion_id === "number" && Number.isFinite(row.opinion_id)
? Math.floor(row.opinion_id)
: typeof row.opinionId === "number" && Number.isFinite(row.opinionId)
? Math.floor(row.opinionId)
: null;
return {
opinionId,
type: typeof row.type === "string" ? row.type : null,
author: typeof row.author === "string" ? row.author : null,
quote: text,
};
})
.filter(
(quote): quote is {
opinionId: number | null;
type: string | null;
author: string | null;
quote: string;
} => !!quote,
);
}
// ---------------------------------------------------------------------------
// Citation block constants and parsers
// ---------------------------------------------------------------------------
export const CITATIONS_BLOCK_RE = /<CITATIONS>\s*([\s\S]*?)\s*<\/CITATIONS>/;
export const CITATIONS_OPEN_TAG = "<CITATIONS>";
export const CITATIONS_CLOSE_TAG = "</CITATIONS>";
type CitationParseDiagnostics = {
hasBlock: boolean;
rawLength: number;
error: string | null;
};
export function parseCitationsWithDiagnostics(text: string): {
citations: ParsedCitation[];
diagnostics: CitationParseDiagnostics;
} {
const match = text.match(CITATIONS_BLOCK_RE);
if (!match) {
return { citations: [], diagnostics: { hasBlock: false, rawLength: 0, error: null } };
}
const raw = match[1] ?? "";
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return {
citations: [],
diagnostics: { hasBlock: true, rawLength: raw.length, error: "CITATIONS block JSON was not an array." },
};
}
return {
citations: parsed.map(normalizeCitation).filter((c): c is ParsedCitation => c !== null),
diagnostics: { hasBlock: true, rawLength: raw.length, error: null },
};
} catch (error) {
return {
citations: [],
diagnostics: {
hasBlock: true,
rawLength: raw.length,
error: error instanceof Error ? error.message : String(error),
},
};
}
}
export function parseCitations(text: string): ParsedCitation[] {
return parseCitationsWithDiagnostics(text).citations;
}
export function parsePartialCitationObjects(text: string): ParsedCitation[] {
const beforeClose = text.split(CITATIONS_CLOSE_TAG)[0] ?? text;
const arrayStart = beforeClose.indexOf("[");
if (arrayStart < 0) return [];
const parsed: ParsedCitation[] = [];
let inString = false;
let escaped = false;
let depth = 0;
let objectStart = -1;
for (let i = arrayStart + 1; i < beforeClose.length; i += 1) {
const char = beforeClose[i];
if (escaped) { escaped = false; continue; }
if (char === "\\") { escaped = inString; continue; }
if (char === '"') { inString = !inString; continue; }
if (inString) continue;
if (char === "{") {
if (depth === 0) objectStart = i;
depth += 1;
} else if (char === "}") {
if (depth === 0) continue;
depth -= 1;
if (depth === 0 && objectStart >= 0) {
try {
const raw = JSON.parse(beforeClose.slice(objectStart, i + 1));
const citation = normalizeCitation(raw);
if (citation) parsed.push(citation);
} catch { /* ignore incomplete/malformed partial object */ }
objectStart = -1;
}
} else if (char === "]" && depth === 0) {
break;
}
}
return parsed;
}
type CasesByClusterId = Map<number, {
caseName: string | null;
citations: string[];
url: string | null;
pdfUrl: string | null;
dateFiled: string | null;
}>;
export function createCitation(
citation: ParsedCitation,
docIndex: DocIndex,
casesByClusterId?: CasesByClusterId,
) {
if (citation.kind === "case") {
const caseRecord = casesByClusterId?.get(citation.cluster_id);
return {
type: "citation_data",
kind: "case",
ref: citation.ref,
cluster_id: citation.cluster_id,
case_name: caseRecord?.caseName ?? null,
citation: caseRecord?.citations[0] ?? null,
url: caseRecord?.url ?? null,
pdfUrl: caseRecord?.pdfUrl ?? null,
dateFiled: caseRecord?.dateFiled ?? null,
quotes: citation.quotes,
};
}
const docInfo = resolveDoc(citation.doc_id, docIndex);
return {
type: "citation_data",
kind: "document",
ref: citation.ref,
doc_id: citation.doc_id,
document_id: docInfo?.document_id,
version_id: docInfo?.version_id ?? null,
version_number: docInfo?.version_number ?? null,
filename: docInfo?.filename ?? citation.doc_id,
page: citation.page,
quote: citation.quote,
sheet: citation.sheet,
cell: citation.cell,
quotes: citation.quotes,
};
}

View file

@ -0,0 +1,583 @@
import { createServerSupabase } from "../supabase";
import {
attachActiveVersionPaths,
} from "../documentVersions";
import {
type DocStore,
type DocIndex,
type WorkflowStore,
type ChatMessage,
type AskInputsResponseRequest,
type AskInputResponseItem,
devLog,
} from "./types";
import { buildSystemPrompt } from "./prompts";
import { parseCitations, createCitation } from "./citations";
import type { AssistantEvent } from "./streaming";
export async function enrichWithPriorEvents(
messages: ChatMessage[],
chatId: string | null | undefined,
db: ReturnType<typeof createServerSupabase>,
docIndex: DocIndex,
): Promise<ChatMessage[]> {
if (!chatId) return messages;
const { data: rows } = await db
.from("chat_messages")
.select("content, created_at")
.eq("chat_id", chatId)
.eq("role", "assistant")
.order("created_at", { ascending: false })
.limit(1);
const lastRow = rows?.[0] as { content?: unknown } | undefined;
const content = lastRow?.content;
if (!Array.isArray(content)) return messages;
const slugByDocumentId = new Map<string, string>();
for (const [slug, info] of Object.entries(docIndex)) {
if (info.document_id) slugByDocumentId.set(info.document_id, slug);
}
const refFor = (documentId: unknown, filename: unknown) => {
const slug =
typeof documentId === "string"
? slugByDocumentId.get(documentId)
: undefined;
return slug ? `${slug} ("${filename}")` : `"${filename}"`;
};
const lines: string[] = [];
for (const ev of content as Record<string, unknown>[]) {
if (ev?.type === "doc_created") {
lines.push(`- generated_document → ${refFor(ev.document_id, ev.filename)}`);
} else if (ev?.type === "doc_edited") {
lines.push(`- edit_document → ${refFor(ev.document_id, ev.filename)}`);
} else if (ev?.type === "doc_read") {
lines.push(`- read_document → ${refFor(ev.document_id, ev.filename)}`);
} else if (ev?.type === "doc_replicated") {
// The model needs to know what each copy resolved to so it
// can call edit_document / read_document on them. Emit one
// line per copy, all attributed back to the same source.
const srcLabel =
typeof ev.filename === "string" ? `"${ev.filename}"` : "";
const copies = Array.isArray(ev.copies)
? (ev.copies as {
new_filename?: unknown;
document_id?: unknown;
}[])
: [];
for (const c of copies) {
const ref = refFor(c.document_id, c.new_filename);
lines.push(
srcLabel
? `- replicate_document → ${ref} (copy of ${srcLabel})`
: `- replicate_document → ${ref}`,
);
}
} else if (ev?.type === "workflow_applied") {
lines.push(`- applied workflow: "${ev.title}"`);
} else if (ev?.type === "ask_inputs") {
const count = Array.isArray(ev.items) ? ev.items.length : 0;
lines.push(`- asked user for ${count} input${count === 1 ? "" : "s"}`);
} else if (ev?.type === "ask_inputs_response") {
const responses = Array.isArray(ev.responses) ? ev.responses : [];
for (const response of responses) {
if (!response || typeof response !== "object") continue;
const row = response as Record<string, unknown>;
if (row.skipped) {
lines.push("- user skipped an input");
} else if (row.kind === "choice" && typeof row.answer === "string") {
lines.push(`- user answered: "${row.answer}"`);
} else if (
row.kind === "documents" &&
Array.isArray(row.filenames)
) {
lines.push(
`- user attached documents: ${row.filenames.join(", ") || "none"}`,
);
}
}
}
}
if (lines.length === 0) return messages;
const summary = `\n\n[Tool activity in your previous turn]\n${lines.join("\n")}`;
// Find the index of the last assistant message and attach the
// summary there only.
let lastAssistantIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIdx = i;
break;
}
}
if (lastAssistantIdx < 0) return messages;
const enriched = messages.slice();
const target = enriched[lastAssistantIdx];
enriched[lastAssistantIdx] = {
...target,
content: (target.content ?? "") + summary,
};
return enriched;
}
export function buildMessages(
messages: ChatMessage[],
docAvailability: {
doc_id: string;
filename: string;
folder_path?: string;
}[],
systemPromptExtra?: string,
docIndex?: DocIndex,
includeResearchTools = true,
) {
const formatted: unknown[] = [];
let systemContent = buildSystemPrompt(includeResearchTools);
if (systemPromptExtra) {
systemContent += `\n\n${systemPromptExtra.trim()}`;
}
if (docAvailability.length) {
systemContent += "\n\n---\nAVAILABLE DOCUMENTS:\n";
for (const doc of docAvailability) {
const label = doc.folder_path
? `${doc.folder_path} / ${doc.filename}`
: doc.filename;
systemContent += `- ${doc.doc_id}: ${label}\n`;
}
systemContent +=
"\nYou do NOT retain document content between conversation turns. You MUST call read_document (or fetch_documents) once at the start of every response that involves a document's content, even if you have read it in a previous turn. Within the same response, do not call read_document or fetch_documents again for a document/version that has already been read; use the prior tool result, find_in_document for targeted checks, or proceed to the next required tool. Failure to read once per turn will result in hallucinated or stale content.\n---\n";
}
formatted.push({ role: "system", content: systemContent });
// Map document_id (UUID) → current-turn doc_id slug, so when we
// inline a user attachment we hand the model the same handle it
// would use to call read_document / fetch_documents.
const slugByDocumentId = new Map<string, string>();
if (docIndex) {
for (const [slug, info] of Object.entries(docIndex)) {
if (info.document_id) slugByDocumentId.set(info.document_id, slug);
}
}
for (const msg of messages) {
let content = msg.content ?? "";
if (msg.role === "user" && msg.workflow) {
content = `[Workflow: ${msg.workflow.title} (id: ${msg.workflow.id})]\n\n${content}`;
}
if (msg.role === "user" && msg.files?.length) {
const lines = msg.files.map((f) => {
const slug = f.document_id
? slugByDocumentId.get(f.document_id)
: undefined;
return slug ? `- ${slug}: ${f.filename}` : `- ${f.filename}`;
});
content = `[The user attached the following document(s) to this message:\n${lines.join("\n")}]\n\n${content}`;
}
formatted.push({ role: msg.role, content });
}
return formatted;
}
export function extractCitations(
fullText: string,
docIndex: DocIndex,
_events?: ({ type: string } & Record<string, unknown>[]) | unknown[],
): unknown[] {
return parseCitations(fullText).map((c) =>
createCitation(c, docIndex),
);
}
export function stripTransientAssistantEvents(events: AssistantEvent[]) {
return events.filter((event) => event.type !== "case_opinions");
}
function cleanAskInputResponseId(value: unknown) {
const id = typeof value === "string" ? value.trim() : "";
return id.slice(0, 80);
}
export function parseAskInputsResponsePayload(
value: unknown,
): AskInputsResponseRequest | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
const rawResponses = Array.isArray(row.responses) ? row.responses : [];
const responses = rawResponses
.map((item): AskInputResponseItem | null => {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const current = item as Record<string, unknown>;
const id = cleanAskInputResponseId(current.id);
const kind = current.kind;
const skipped = current.skipped === true;
if (!id || (kind !== "choice" && kind !== "documents")) return null;
if (kind === "choice") {
const question =
typeof current.question === "string"
? current.question.trim().slice(0, 500)
: "";
const answer =
typeof current.answer === "string"
? current.answer.trim().slice(0, 1000)
: "";
if (!question || (!answer && !skipped)) return null;
return {
id,
kind,
question,
...(answer ? { answer } : {}),
...(skipped ? { skipped: true } : {}),
};
}
const rawFilenames = Array.isArray(current.filenames)
? current.filenames
: [];
const filenames = rawFilenames
.filter((f): f is string => typeof f === "string")
.map((f) => f.trim())
.filter(Boolean)
.slice(0, 50);
return {
id,
kind,
filenames,
...(skipped ? { skipped: true } : {}),
};
})
.filter((item): item is AskInputResponseItem => !!item)
.slice(0, 20);
return responses.length > 0 ? { responses } : null;
}
export async function appendAskInputsResponseToLastAssistantMessage(
db: ReturnType<typeof createServerSupabase>,
chatId: string,
response: AskInputsResponseRequest,
) {
await appendAssistantEventsToLastAssistantMessage(db, chatId, [
{
type: "ask_inputs_response" as const,
responses: response.responses,
},
]);
}
export async function appendAssistantEventsToLastAssistantMessage(
db: ReturnType<typeof createServerSupabase>,
chatId: string,
events: AssistantEvent[],
citations?: unknown[],
) {
if (events.length === 0 && (!citations || citations.length === 0)) {
return;
}
const { data: rows, error: selectError } = await db
.from("chat_messages")
.select("id, content, citations")
.eq("chat_id", chatId)
.eq("role", "assistant")
.order("created_at", { ascending: false })
.limit(1);
if (selectError || !rows?.[0]) {
if (selectError) {
console.error(
"[assistant-events] failed to load assistant message",
selectError,
);
}
return;
}
const row = rows[0] as {
id: string;
content: unknown;
citations?: unknown;
};
const existing = Array.isArray(row.content)
? row.content
: [];
const next = [...existing, ...events];
const existingCitations = Array.isArray(row.citations)
? row.citations
: [];
const nextCitations =
citations && citations.length > 0
? [...existingCitations, ...citations]
: existingCitations;
const { error: updateError } = await db
.from("chat_messages")
.update({
content: next.length ? next : null,
citations: nextCitations.length ? nextCitations : null,
})
.eq("id", row.id);
if (updateError) {
console.error(
"[assistant-events] failed to update assistant message",
updateError,
);
}
}
export function appendCancelledAssistantEvent(events: AssistantEvent[]) {
return [...events, { type: "content" as const, text: "Cancelled by user." }];
}
export function buildCancelledAssistantMessage(args: {
fullText: string;
events: AssistantEvent[];
buildCitations: (fullText: string, events: AssistantEvent[]) => unknown[];
}) {
const events = appendCancelledAssistantEvent(
stripTransientAssistantEvents(args.events),
);
return {
events,
citations: args.buildCitations(args.fullText, events),
};
}
// ---------------------------------------------------------------------------
// Document context builder (from message file attachments)
// ---------------------------------------------------------------------------
export async function buildDocContext(
messages: ChatMessage[],
userId: string,
db: ReturnType<typeof createServerSupabase>,
chatId?: string | null,
): Promise<{ docIndex: DocIndex; docStore: DocStore }> {
const docIndex: DocIndex = {};
const docStore: DocStore = new Map();
const documentIds = new Set<string>();
for (const m of messages) {
for (const f of m.files ?? []) {
if (f.document_id) documentIds.add(f.document_id);
}
}
// Also pull in document_ids from prior assistant events in this chat —
// generated docs (generate_docx) and tracked-change edits (edit_document)
// aren't attached to user messages as files, so they only live in the
// assistant's `doc_created` / `doc_edited` events. Without this sweep
// the model loses access to generated docs after the turn that created
// them, and can't call edit_document / read_document on them.
if (chatId) {
const { data: rows } = await db
.from("chat_messages")
.select("content")
.eq("chat_id", chatId)
.eq("role", "assistant");
for (const row of rows ?? []) {
const content = (row as { content?: unknown }).content;
if (!Array.isArray(content)) continue;
for (const ev of content as Record<string, unknown>[]) {
if (
(ev?.type === "doc_created" || ev?.type === "doc_edited") &&
typeof ev.document_id === "string"
) {
documentIds.add(ev.document_id);
}
}
}
}
const ids = [...documentIds];
if (ids.length > 0) {
const { data: docs } = await db
.from("documents")
.select("id, current_version_id, status")
.in("id", ids)
.eq("user_id", userId)
.eq("status", "ready");
const docList = (docs ?? []) as unknown as {
id: string;
filename?: string | null;
file_type?: string | null;
current_version_id?: string | null;
active_version_number?: number | null;
storage_path?: string | null;
}[];
await attachActiveVersionPaths(db, docList);
for (let i = 0; i < docList.length; i++) {
const doc = docList[i];
if (!doc.storage_path) continue;
const docLabel = `doc-${i}`;
const filename = doc.filename?.trim() || "Untitled document";
docIndex[docLabel] = {
document_id: doc.id,
filename,
version_id: doc.current_version_id ?? null,
version_number: doc.active_version_number ?? null,
};
docStore.set(docLabel, {
storage_path: doc.storage_path,
file_type: doc.file_type ?? "",
filename,
});
}
}
devLog(
"[buildDocContext] available docs:",
Object.entries(docIndex).map(([label, info]) => ({
label,
filename: info.filename,
document_id: info.document_id,
})),
);
return { docIndex, docStore };
}
export async function buildProjectDocContext(
projectId: string,
_userId: string,
db: ReturnType<typeof createServerSupabase>,
): Promise<{
docIndex: DocIndex;
docStore: DocStore;
folderPaths: Map<string, string>;
}> {
const docIndex: DocIndex = {};
const docStore: DocStore = new Map();
const [{ data: docs }, { data: folders }] = await Promise.all([
db
.from("documents")
.select("id, current_version_id, status, folder_id")
.eq("project_id", projectId)
.eq("status", "ready")
.order("created_at", { ascending: true }),
db
.from("project_subfolders")
.select("id, name, parent_folder_id")
.eq("project_id", projectId),
]);
const docList = (docs ?? []) as unknown as {
id: string;
filename?: string | null;
file_type?: string | null;
current_version_id?: string | null;
active_version_number?: number | null;
folder_id?: string | null;
storage_path?: string | null;
}[];
await attachActiveVersionPaths(db, docList);
// Build folder id → full path map
const folderMap = new Map<
string,
{ name: string; parent_folder_id: string | null }
>();
for (const f of folders ?? [])
folderMap.set(f.id, {
name: f.name,
parent_folder_id: f.parent_folder_id,
});
function resolvePath(folderId: string | null): string {
if (!folderId) return "";
const parts: string[] = [];
let cur: string | null = folderId;
while (cur) {
const f = folderMap.get(cur);
if (!f) break;
parts.unshift(f.name);
cur = f.parent_folder_id;
}
return parts.join(" / ");
}
const folderPaths = new Map<string, string>(); // doc label → folder path
for (let i = 0; i < docList.length; i++) {
const doc = docList[i];
if (!doc.storage_path) continue;
const docLabel = `doc-${i}`;
const filename = doc.filename?.trim() || "Untitled document";
docIndex[docLabel] = {
document_id: doc.id,
filename,
version_id: doc.current_version_id ?? null,
version_number: doc.active_version_number ?? null,
};
docStore.set(docLabel, {
storage_path: doc.storage_path,
file_type: doc.file_type ?? "",
filename,
});
const path = resolvePath(doc.folder_id ?? null);
if (path) folderPaths.set(docLabel, path);
}
devLog(
"[buildProjectDocContext] available docs:",
Object.entries(docIndex).map(([label, info]) => ({
label,
filename: info.filename,
document_id: info.document_id,
folder: folderPaths.get(label) ?? null,
})),
);
return { docIndex, docStore, folderPaths };
}
export async function buildWorkflowStore(
userId: string,
userEmail: string | null | undefined,
db: ReturnType<typeof createServerSupabase>,
): Promise<WorkflowStore> {
const { SYSTEM_ASSISTANT_WORKFLOWS } = await import("../systemWorkflows");
const store: WorkflowStore = new Map();
const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase();
// Seed system workflows first.
for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
store.set(wf.id, { title: wf.title, 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;
}

View file

@ -0,0 +1,8 @@
export * from "./types";
export * from "./prompts";
export * from "./tools/toolSchemas";
export * from "./citations";
export * from "./tools/documentOps";
export * from "./tools/toolDispatcher";
export * from "./streaming";
export * from "./contextBuilders";

View file

@ -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: <title> (id: <id>)], immediately call read_workflow with that id and follow the workflow before doing anything else.
- If you need the user to choose between options, clarify a missing premise, or attach one or more documents before you can continue, call ask_inputs with all needed choice and document-upload items in a single tool call. For document-upload items, include a document_types array with short labels for the specific categories of documents you need. After asking, do not continue the substantive task until the user responds in a later message.
DOCUMENT CITATIONS:
Use document citations only for verbatim evidence from uploaded or generated documents.
In prose, put sequential markers [1], [2], etc. exactly where the cited claim appears. Assign citation refs in first-appearance order and increment by exactly 1 each time: [1], [2], [3], never [1], [2], [3], [4], [5], [8], [9]. The marker number is the citation "ref" value, not a page, footnote, section, clause, or document number.
At the very end of the response, append:
<CITATIONS>
[
{"ref": 1, "doc_id": "doc-0", "quotes": [{"page": 3, "quote": "exact verbatim text"}]},
{"ref": 2, "doc_id": "doc-1", "quotes": [{"page": "41-42", "quote": "text before page break [[PAGE_BREAK]] text after page break"}]}
]
</CITATIONS>
Citation rules:
- Every [N] marker must have exactly one matching entry with "ref": N.
- Citation refs must be contiguous with no skipped numbers. If the response uses N citations, the refs must be exactly 1 through N, and the <CITATIONS> array should list them in that order.
- Bracketed numbers like [1] are only citation annotation markers. Do not add brackets to section, clause, schedule, exhibit, paragraph, or list numbering.
- "doc_id" must be the exact chat-local label you were given, such as "doc-0". Never use a filename or document UUID in "doc_id".
- Use one citation entry per marker. If one marker needs several passages, use "quotes" with 1 quote by default and at most 3.
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
- For spreadsheet sources (content shown as "## Sheet: <name>" markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
- 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);

View file

@ -0,0 +1,553 @@
import {
streamChatWithTools,
resolveModel,
DEFAULT_MAIN_MODEL,
type LlmMessage,
type OpenAIToolSchema,
} from "../llm";
import { safeErrorMessage } from "../safeError";
import { createServerSupabase } from "../supabase";
import {
buildUserMcpTools,
type McpToolEvent,
} from "../mcpConnectors";
import {
COURTLISTENER_TOOLS,
type CaseCitationEvent,
type CourtlistenerToolEvent,
} from "./tools/courtlistenerTools";
import {
type DocStore,
type DocIndex,
type TabularCellStore,
type WorkflowStore,
type ToolCall,
type AskInputsEvent,
type EditAnnotation,
devLog,
} from "./types";
import { TOOLS, WORKFLOW_TOOLS } from "./tools/toolSchemas";
import {
parseCitationsWithDiagnostics,
parsePartialCitationObjects,
createCitation,
CITATIONS_OPEN_TAG,
} from "./citations";
import {
runToolCalls,
type CourtlistenerTurnState,
} from "./tools/toolDispatcher";
import {
type TurnEditState,
type TurnReadState,
} from "./tools/documentOps";
export type AssistantEvent =
| { type: "reasoning"; text: string }
| AskInputsEvent
| {
type: "ask_inputs_response";
responses: {
id: string;
kind: "choice" | "documents";
question?: string;
answer?: string;
filenames?: string[];
skipped?: boolean;
}[];
}
| { type: "doc_read"; filename: string; document_id?: string }
| {
type: "doc_find";
filename: string;
query: string;
total_matches: number;
}
| {
type: "doc_created";
filename: string;
download_url: string;
document_id?: string;
version_id?: string;
version_number?: number | null;
}
| { type: "doc_download"; filename: string; download_url: string }
| {
type: "doc_replicated";
/** Source document being copied. */
filename: string;
count: number;
copies: {
new_filename: string;
document_id: string;
version_id: string;
}[];
}
| { type: "workflow_applied"; workflow_id: string; title: string }
| {
type: "doc_edited";
filename: string;
document_id: string;
version_id: string;
/** Per-document monotonic Vn; null if backend couldn't determine it. */
version_number: number | null;
download_url: string;
annotations: EditAnnotation[];
}
| CaseCitationEvent
| CourtlistenerToolEvent
| McpToolEvent
| { type: "case_opinions"; cluster_id: number; case: unknown }
| { type: "content"; text: string }
| { type: "error"; message: string };
export class AssistantStreamError extends Error {
fullText: string;
events: AssistantEvent[];
constructor(message: string, fullText: string, events: AssistantEvent[]) {
super(message);
this.name = "AssistantStreamError";
this.fullText = fullText;
this.events = events;
}
}
export class AssistantStreamAbortError extends AssistantStreamError {
constructor(fullText: string, events: AssistantEvent[]) {
super("Stream aborted.", fullText, events);
this.name = "AbortError";
}
}
class AssistantStreamAskInputsPause extends Error {
constructor() {
super("Waiting for user input.");
this.name = "AssistantStreamAskInputsPause";
}
}
export function isAbortError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const record = error as { name?: unknown; message?: unknown };
return (
record.name === "AbortError" || record.message === "Stream aborted."
);
}
function throwIfAborted(signal?: AbortSignal) {
if (!signal?.aborted) return;
const err = new Error("Stream aborted.");
err.name = "AbortError";
throw err;
}
export async function runLLMStream(params: {
apiMessages: unknown[];
docStore: DocStore;
docIndex: DocIndex;
userId: string;
db: ReturnType<typeof createServerSupabase>;
write: (s: string) => void;
extraTools?: unknown[];
includeResearchTools?: boolean;
workflowStore?: WorkflowStore;
tabularStore?: TabularCellStore;
buildCitations?: (fullText: string) => unknown[];
model?: string;
apiKeys?: import("../llm").UserApiKeys;
signal?: AbortSignal;
/**
* If set, generate_docx will attach created docs to this project so
* they appear in the project sidebar. Leave null for general chats
* generated docs still get persisted, but as standalone documents.
*/
projectId?: string | null;
}): Promise<{
fullText: string;
events: AssistantEvent[];
citations: unknown[];
}> {
const {
apiMessages,
docStore,
docIndex,
userId,
db,
write,
extraTools,
includeResearchTools = true,
workflowStore,
tabularStore,
buildCitations,
model,
apiKeys,
signal,
projectId,
} = params;
const researchTools = includeResearchTools ? COURTLISTENER_TOOLS : [];
const mcpTools = await buildUserMcpTools(userId, db);
const baseTools = [...TOOLS, ...researchTools, ...WORKFLOW_TOOLS];
const activeTools = extraTools?.length
? [...baseTools, ...mcpTools, ...extraTools]
: [...baseTools, ...mcpTools];
// Extract system prompt; pass remaining turns to the adapter as
// plain user/assistant messages.
const rawMsgs = apiMessages as { role: string; content: string | null }[];
const systemPrompt =
rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : "";
const chatMessages: LlmMessage[] = rawMsgs
.filter((m) => m.role !== "system")
.map((m) => ({
role: m.role === "assistant" ? "assistant" : "user",
content: m.content ?? "",
}));
const events: AssistantEvent[] = [];
// One assistant turn produces at most one document_versions row per
// edited doc. `runToolCalls` fires once per tool-call batch; the model
// may emit multiple batches in a single turn, so this map persists
// across batches to let subsequent edit_document calls overwrite the
// turn's existing version instead of creating a new one.
const turnEditState: TurnEditState = new Map();
// Suppress repeated full-document reads for the same document/version in
// one assistant response. The guard is invalidated when edit_document
// changes that document so a post-edit verification read can still happen.
const turnReadState: TurnReadState = new Map();
const courtlistenerTurnState: CourtlistenerTurnState = {
casesByClusterId: new Map(),
};
let fullText = "";
let iterText = "";
let iterVisibleText = "";
let iterReasoning = "";
let visibleTailBuffer = "";
let citationsOpenSeen = false;
let streamingCitationsBuffer = "";
let streamedCitationCount = 0;
const emitCitationStreamSnapshot = (
status: "started" | "partial",
citations: unknown[],
) => {
if (buildCitations) return;
write(`data: ${JSON.stringify({ type: "citations", status, citations })}\n\n`);
};
const streamHiddenCitationContent = (delta: string) => {
if (buildCitations || !delta) return;
streamingCitationsBuffer += delta;
const partial = parsePartialCitationObjects(streamingCitationsBuffer);
if (partial.length <= streamedCitationCount) return;
streamedCitationCount = partial.length;
const citations = partial.map((c) =>
createCitation(
c,
docIndex,
courtlistenerTurnState.casesByClusterId,
),
);
emitCitationStreamSnapshot("partial", citations);
};
const streamVisibleContent = (delta: string) => {
if (!delta) return;
if (citationsOpenSeen) {
streamHiddenCitationContent(delta);
return;
}
const combined = visibleTailBuffer + delta;
const markerIdx = combined.indexOf(CITATIONS_OPEN_TAG);
if (markerIdx >= 0) {
const visible = combined.slice(0, markerIdx);
if (visible) {
iterVisibleText += visible;
write(
`data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
);
}
visibleTailBuffer = "";
citationsOpenSeen = true;
streamingCitationsBuffer = "";
streamedCitationCount = 0;
emitCitationStreamSnapshot("started", []);
streamHiddenCitationContent(
combined.slice(markerIdx + CITATIONS_OPEN_TAG.length),
);
return;
}
const keep = Math.min(CITATIONS_OPEN_TAG.length - 1, combined.length);
const visible = combined.slice(0, combined.length - keep);
visibleTailBuffer = combined.slice(combined.length - keep);
if (visible) {
iterVisibleText += visible;
write(
`data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
);
}
};
const flushVisibleTail = (opts: { emit?: boolean } = {}) => {
const emit = opts.emit ?? true;
if (citationsOpenSeen || !visibleTailBuffer) {
visibleTailBuffer = "";
return;
}
iterVisibleText += visibleTailBuffer;
if (emit) {
write(
`data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
);
}
visibleTailBuffer = "";
};
const flushText = (opts: { emit?: boolean } = {}) => {
if (!iterText) return;
fullText += iterText;
flushVisibleTail(opts);
if (iterVisibleText) {
events.push({ type: "content", text: iterVisibleText });
}
iterText = "";
iterVisibleText = "";
visibleTailBuffer = "";
citationsOpenSeen = false;
streamingCitationsBuffer = "";
streamedCitationCount = 0;
};
const flushPartialTurn = (opts: { emit?: boolean } = {}) => {
flushText(opts);
if (iterReasoning) {
events.push({ type: "reasoning", text: iterReasoning });
iterReasoning = "";
}
};
const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL);
try {
throwIfAborted(signal);
await streamChatWithTools({
model: selectedModel,
systemPrompt,
messages: chatMessages,
tools: activeTools as OpenAIToolSchema[],
maxIterations: 10,
apiKeys,
enableThinking: true,
abortSignal: signal,
callbacks: {
onContentDelta: (delta) => {
iterText += delta;
streamVisibleContent(delta);
},
onReasoningDelta: (delta) => {
iterReasoning += delta;
write(
`data: ${JSON.stringify({ type: "reasoning_delta", text: delta })}\n\n`,
);
},
onReasoningBlockEnd: () => {
if (!iterReasoning) return;
events.push({ type: "reasoning", text: iterReasoning });
write(`data: ${JSON.stringify({ type: "reasoning_block_end" })}\n\n`);
iterReasoning = "";
},
// Fires after Claude's turn ends with stop_reason=tool_use, before
// the tool actually runs. Flushes any buffered assistant text so
// it's emitted in chronological order, then signals the client so
// it can open a fresh PreResponseWrapper (shows "Working…") while
// the tool executes — avoids the dead gap between message_stop
// and the first tool-specific event.
onToolCallStart: (call) => {
flushText();
write(
`data: ${JSON.stringify({
type: "tool_call_start",
name: call.name,
})}\n\n`,
);
},
},
runTools: async (calls) => {
throwIfAborted(signal);
// Emit any text the model produced before this tool turn so the
// UI sees it before the tool results stream in.
flushText();
const toolCalls: ToolCall[] = calls.map((c) => ({
id: c.id,
function: {
name: c.name,
arguments: JSON.stringify(c.input),
},
}));
const {
toolResults,
docsRead,
docsFound,
docsCreated,
docsReplicated,
workflowsApplied,
docsEdited,
askInputsEvents,
courtlistenerEvents,
caseCitationEvents,
mcpEvents,
} = await runToolCalls(
toolCalls,
docStore,
userId,
db,
write,
workflowStore,
tabularStore,
docIndex,
turnEditState,
turnReadState,
projectId,
courtlistenerTurnState,
apiKeys,
);
throwIfAborted(signal);
for (const r of docsRead) {
events.push({
type: "doc_read",
filename: r.filename,
document_id: r.document_id,
});
}
for (const f of docsFound) {
events.push({
type: "doc_find",
filename: f.filename,
query: f.query,
total_matches: f.total_matches,
});
}
for (const dl of docsCreated) {
events.push({
type: "doc_created",
filename: dl.filename,
download_url: dl.download_url,
document_id: dl.document_id,
version_id: dl.version_id,
version_number: dl.version_number ?? null,
});
}
for (const r of docsReplicated) {
events.push({
type: "doc_replicated",
filename: r.filename,
count: r.count,
copies: r.copies,
});
}
for (const wf of workflowsApplied) {
events.push({
type: "workflow_applied",
workflow_id: wf.workflow_id,
title: wf.title,
});
}
for (const e of docsEdited) {
events.push({
type: "doc_edited",
filename: e.filename,
document_id: e.document_id,
version_id: e.version_id,
version_number: e.version_number,
download_url: e.download_url,
annotations: e.annotations,
});
}
for (const askInputsEvent of askInputsEvents) {
write(`data: ${JSON.stringify(askInputsEvent)}\n\n`);
events.push(askInputsEvent);
}
for (const event of courtlistenerEvents) {
events.push(event);
}
for (const event of mcpEvents) {
events.push(event);
}
for (const event of caseCitationEvents) {
events.push(event);
}
if (askInputsEvents.length > 0) {
throw new AssistantStreamAskInputsPause();
}
// Index alignment would break if any tool branch skips its
// push (unhandled tool name, disabled store, guard failure).
// Each tool_result already carries its tool_call_id, so key off
// that directly — and fall back to an error result for any
// tool_use that didn't produce one, so Claude's next request
// has a tool_result for every tool_use it sent.
const resultByCallId = new Map<string, string>();
for (const r of toolResults) {
const row = r as { tool_call_id: string; content?: unknown };
resultByCallId.set(row.tool_call_id, String(row.content ?? ""));
}
return toolCalls.map((c) => ({
tool_use_id: c.id,
content:
resultByCallId.get(c.id) ??
JSON.stringify({
error: `Tool '${c.function.name}' is not available.`,
}),
}));
},
});
} catch (err) {
if (err instanceof AssistantStreamAskInputsPause) {
// The ask_inputs event has already been emitted and persisted in `events`.
// Stop this assistant turn here so the model does not add redundant
// prose telling the user to answer the picker or attach documents.
} else if (isAbortError(err)) {
flushPartialTurn({ emit: false });
throw new AssistantStreamAbortError(fullText, events);
} else {
flushPartialTurn();
const message = safeErrorMessage(err, "Stream error");
events.push({ type: "error", message });
throw new AssistantStreamError(message, fullText, events);
}
}
flushText();
// Parse and emit citations from <CITATIONS> block
const { citations: parsedCitations, diagnostics: citationDiagnostics } =
parseCitationsWithDiagnostics(fullText);
const citations = buildCitations
? buildCitations(fullText)
: parsedCitations.map((c) =>
createCitation(
c,
docIndex,
courtlistenerTurnState.casesByClusterId,
),
);
devLog("[chat/stream] final citations", {
hasCitationsBlock: citationDiagnostics.hasBlock,
citationsBlockLength: citationDiagnostics.rawLength,
parseError: citationDiagnostics.error,
parsedCitationCount: parsedCitations.length,
emittedCitationCount: citations.length,
usedCustomCitationBuilder: !!buildCitations,
});
write(
`data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`,
);
write("data: [DONE]\n\n");
return { fullText, events, citations };
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,471 @@
export const PROJECT_EXTRA_TOOLS = [
{
type: "function",
function: {
name: "list_documents",
description:
"List all documents available in the project. Returns each document's ID, filename, and file type. Call this to discover what documents are available before deciding which ones to read.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "fetch_documents",
description:
"Read the full text content of multiple documents in a single call. Use this instead of calling read_document repeatedly when you need to read several documents at once. In one response, fetch each document/version at most once; after it has been fetched, use the prior tool result or find_in_document for targeted checks.",
parameters: {
type: "object",
properties: {
doc_ids: {
type: "array",
items: { type: "string" },
description:
"Array of document IDs to read (e.g. ['doc-0', 'doc-2'])",
},
},
required: ["doc_ids"],
},
},
},
{
type: "function",
function: {
name: "replicate_document",
description:
"Make byte-for-byte copies of an existing project document as new project documents. Use when the user wants standalone copies to edit (e.g. 'use this NDA as a template', 'give me three drafts I can adapt') without modifying the original. Pass `count` to create multiple copies in a single call rather than calling the tool repeatedly. Returns the new doc_id slugs so you can immediately call edit_document / read_document on them.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "ID of the source document to copy (e.g. 'doc-0').",
},
count: {
type: "integer",
description:
"How many copies to create. Defaults to 1. Maximum 20.",
minimum: 1,
maximum: 20,
},
new_filename: {
type: "string",
description:
"Optional base filename. With count > 1, copies are suffixed (e.g. 'Foo (1).docx', 'Foo (2).docx'). Extension is forced to match the source.",
},
},
required: ["doc_id"],
},
},
},
];
export const TABULAR_TOOLS = [
{
type: "function",
function: {
name: "read_table_cells",
description:
"Read the extracted cell content from the tabular review. Each cell contains the value extracted for a specific column from a specific document. Pass col_indices and/or row_indices (0-based) to read a subset; omit either to read all columns or all rows.",
parameters: {
type: "object",
properties: {
col_indices: {
type: "array",
items: { type: "integer" },
description:
"0-based column indices to read (e.g. [0, 2]). Omit to read all columns.",
},
row_indices: {
type: "array",
items: { type: "integer" },
description:
"0-based document (row) indices to read (e.g. [0, 1]). Omit to read all rows.",
},
},
},
},
},
];
export const WORKFLOW_TOOLS = [
{
type: "function",
function: {
name: "list_workflows",
description:
"List all workflows available to the user. Returns each workflow's ID and title. Call this when the user asks to run a workflow, apply a template, or you need to discover what workflows exist.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "read_workflow",
description:
"Read the full instructions (prompt) of a workflow by its ID. Call this after list_workflows to load a specific workflow's prompt, then follow those instructions.",
parameters: {
type: "object",
properties: {
workflow_id: {
type: "string",
description: "The workflow ID to read",
},
},
required: ["workflow_id"],
},
},
},
];
export const TOOLS = [
{
type: "function",
function: {
name: "ask_inputs",
description:
"Ask the user for one or more decisions, clarifications, or document uploads before continuing. Use this when guessing would materially affect the answer or when required documents have not been attached. Put all needed questions and document requests in one items array. After calling ask_inputs, do not continue the substantive task until the user responds in a later message.",
parameters: {
type: "object",
properties: {
items: {
type: "array",
minItems: 1,
maxItems: 12,
description:
"The list of user inputs needed before continuing. Use choice items for decisions/clarifications and documents items for required uploads.",
items: {
type: "object",
properties: {
id: {
type: "string",
description:
"Stable short ID for this input, unique within this tool call.",
},
kind: {
type: "string",
enum: ["choice", "documents"],
},
question: {
type: "string",
description:
"For choice items only: the concise question to show to the user.",
},
options: {
type: "array",
description:
"For choice items only: selectable choices to show. Each choice has a single user-facing value, which is also sent back if selected.",
minItems: 1,
maxItems: 8,
items: {
type: "object",
properties: {
value: {
type: "string",
description: "The user-facing choice text.",
},
},
required: ["value"],
},
},
allow_other: {
type: "boolean",
description:
"For choice items only: whether to show an Other option with a text field. Defaults to true.",
},
other_label: {
type: "string",
description:
"For choice items only: label for the free-text option. Defaults to Other.",
},
document_types: {
type: "array",
description:
"For documents items only: readable labels for the types of documents you need the user to attach.",
minItems: 1,
maxItems: 8,
items: {
type: "string",
},
},
response_prefix: {
type: "string",
description:
"Optional prefix the UI should include when sending this response back as the next message.",
},
},
required: ["id", "kind"],
},
},
},
required: ["items"],
},
},
},
{
type: "function",
function: {
name: "read_document",
description:
"Read the full text content of a document attached by the user. Always call this before answering questions about, summarising, citing from, or editing a document, but call it at most once per document/version in a single response. After this returns, use the prior tool result or find_in_document for targeted checks instead of reading the same document/version again.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "The document ID to read (e.g. 'doc-0', 'doc-1')",
},
},
required: ["doc_id"],
},
},
},
{
type: "function",
function: {
name: "find_in_document",
description:
"Search for specific strings inside a document — a Ctrl+F equivalent. Returns each match with surrounding context so you can locate and quote the exact text without reading the whole document. Matching is case-insensitive and whitespace-tolerant. Use this for targeted lookups (e.g. finding a clause title, party name, or a specific phrase) rather than reading the whole document.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "The document ID to search (e.g. 'doc-0').",
},
query: {
type: "string",
description:
"The string to search for. Matching is case-insensitive and collapses runs of whitespace, so 'Section 4.2' matches 'section 4.2'.",
},
max_results: {
type: "integer",
description:
"Maximum number of matches to return (default 20). Use a smaller value for common terms.",
},
context_chars: {
type: "integer",
description:
"Characters of surrounding context to include on each side of a match (default 80).",
},
},
required: ["doc_id", "query"],
},
},
},
{
type: "function",
function: {
name: "generate_docx",
description:
"Generate a Word (.docx) document from structured content. Use this when the user asks you to draft, create, or produce a legal document. Returns a download URL for the generated file.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Document title (used as filename and heading)",
},
landscape: {
type: "boolean",
description:
"Set to true for landscape page orientation. Default is portrait.",
},
sections: {
type: "array",
description:
"List of document sections. Each section may contain a heading, prose content, or a table.",
items: {
type: "object",
properties: {
heading: {
type: "string",
description: "Optional section heading",
},
level: {
type: "integer",
description: "Heading level: 1, 2, or 3",
},
content: {
type: "string",
description:
"Prose text content (paragraphs separated by double newlines)",
},
pageBreak: {
type: "boolean",
description:
"Set to true to start this section on a new page. Use for contract signature pages.",
},
table: {
type: "object",
description: "Optional table to render in this section",
properties: {
headers: {
type: "array",
items: { type: "string" },
description: "Column header labels",
},
rows: {
type: "array",
items: {
type: "array",
items: { type: "string" },
},
description:
"Array of rows, each row is an array of cell strings matching the headers order",
},
},
required: ["headers", "rows"],
},
},
},
},
},
required: ["title", "sections"],
},
},
},
{
type: "function",
function: {
name: "generate_excel",
description:
"Generate an Excel (.xlsx) workbook from structured sheet data. Use this when the user asks for a spreadsheet, tracker, matrix, checklist, schedule, or Excel file. Returns a download URL for the generated file.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Workbook title, used as the filename.",
},
sheets: {
type: "array",
description:
"Workbook sheets. Each sheet has a name, columns, and rows. Row values should follow the columns order.",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Sheet tab name. Keep it short.",
},
columns: {
type: "array",
items: { type: "string" },
description: "Column header labels.",
},
rows: {
type: "array",
items: {
type: "array",
items: { type: "string" },
},
description:
"Array of rows, each row an array of cell strings matching the columns order.",
},
},
required: ["name", "columns", "rows"],
},
},
},
required: ["title", "sheets"],
},
},
},
{
type: "function",
function: {
name: "generate_ppt",
description:
"Generate a PowerPoint (.pptx) presentation from structured slides. Use this when the user asks for slides, a deck, presentation, or PowerPoint file. Returns a download URL for the generated file.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Presentation title, used as the filename.",
},
slides: {
type: "array",
description:
"Slides in order. Each slide may have a title, bullets, and optional speaker notes.",
items: {
type: "object",
properties: {
title: {
type: "string",
description: "Slide title.",
},
bullets: {
type: "array",
items: { type: "string" },
description:
"Main bullet points for the slide. Keep each bullet concise.",
},
notes: {
type: "string",
description:
"Optional speaker notes. Included as text on a notes slide placeholder is not supported; use only for generation context.",
},
},
required: ["title", "bullets"],
},
},
},
required: ["title", "slides"],
},
},
},
{
type: "function",
function: {
name: "edit_document",
description:
"Propose edits to a user-attached .docx as tracked changes. Each edit is a precise, minimal substitution of specific words/characters, NOT a whole-line or paragraph replacement. Use read_document first unless this same document/version has already been read in the current response. Anchor each edit with short before/after context so it can be located unambiguously. Returns per-edit annotations the UI will render as Accept/Reject cards and a download link to the edited document.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "Document slug (e.g. 'doc-0').",
},
edits: {
type: "array",
description: "List of precise substitutions.",
items: {
type: "object",
properties: {
find: {
type: "string",
description:
"Exact substring to replace (keep it as short as possible — ideally just the words/chars being changed).",
},
replace: {
type: "string",
description:
"Replacement text. Empty string = pure deletion.",
},
context_before: {
type: "string",
description:
"~40 chars immediately preceding `find`, used to disambiguate.",
},
context_after: {
type: "string",
description: "~40 chars immediately following `find`.",
},
reason: {
type: "string",
description:
"Short explanation shown to the user on the card.",
},
},
required: ["find", "replace", "context_before", "context_after"],
},
},
},
required: ["doc_id", "edits"],
},
},
},
];

View file

@ -0,0 +1,153 @@
import path from "path";
export const STANDARD_FONT_DATA_URL = (() => {
try {
const pkgPath = require.resolve("pdfjs-dist/package.json");
return path.join(path.dirname(pkgPath), "standard_fonts") + path.sep;
} catch {
return undefined;
}
})();
const isDev = process.env.NODE_ENV !== "production";
export const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
// ---------------------------------------------------------------------------
// Core types
// ---------------------------------------------------------------------------
export type DocStore = Map<
string,
{ storage_path: string; file_type: string; filename: string }
>;
export type WorkflowStore = Map<string, { title: string; 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";
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
export const ALLOWED_DOCUMENT_TYPES = new Set([
"pdf",
"docx",
"doc",
"xlsx",
"xlsm",
"xls",
"pptx",
"ppt",
]);
export const ALLOWED_DOCUMENT_TYPES_LABEL =
"pdf, docx, doc, xlsx, xlsm, xls, pptx, ppt";
const WORD_TYPES = new Set(["docx", "doc"]);
const SPREADSHEET_TYPES = new Set(["xlsx", "xlsm", "xls"]);
const PRESENTATION_TYPES = new Set(["pptx", "ppt"]);
export function isWordDocumentType(fileType: string | null | undefined) {
return WORD_TYPES.has((fileType ?? "").toLowerCase());
}
export function isSpreadsheetDocumentType(fileType: string | null | undefined) {
return SPREADSHEET_TYPES.has((fileType ?? "").toLowerCase());
}
export function isPresentationDocumentType(fileType: string | null | undefined) {
return PRESENTATION_TYPES.has((fileType ?? "").toLowerCase());
}
export function shouldConvertToPdf(fileType: string | null | undefined) {
const normalized = (fileType ?? "").toLowerCase();
// Spreadsheets are intentionally excluded: they are rendered natively as a
// grid in the frontend (Fortune-sheet) from the raw file bytes rather than a
// PDF rendition, which clipped wide/large sheets.
return (
isWordDocumentType(normalized) || isPresentationDocumentType(normalized)
);
}
export function contentTypeForDocumentType(fileType: string | null | undefined) {
switch ((fileType ?? "").toLowerCase()) {
case "pdf":
return "application/pdf";
case "docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
case "xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case "xlsm":
return "application/vnd.ms-excel.sheet.macroEnabled.12";
case "xls":
return "application/vnd.ms-excel";
case "pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
case "ppt":
return "application/vnd.ms-powerpoint";
default:
return "application/octet-stream";
}
}

View file

@ -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;
}

View file

@ -0,0 +1,53 @@
import JSZip from "jszip";
function decodeXml(text: string) {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/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();
}

View file

@ -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 LibreOfficePDFtext 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();
}

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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,

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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,
);
}

View file

@ -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

View file

@ -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) {

View file

@ -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,
);
}

View file

@ -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(

View file

@ -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;

View file

@ -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" });

View file

@ -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=="],

View file

@ -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",

View file

@ -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",

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import { accountGlassSectionClassName } from "./accountStyles";
export function AccountSection({

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import { Loader2 } from "lucide-react";
type AccountToggleSize = "sm" | "md";

View file

@ -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",

View file

@ -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,

View file

@ -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,

View file

@ -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() {

View file

@ -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 {

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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">

View file

@ -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";

View file

@ -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)}

View file

@ -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 }>;

View file

@ -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}
/>
</>
);

View file

@ -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)}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,730 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
AlertCircle,
Check,
CornerDownLeft,
Loader2,
Upload,
X,
} from "lucide-react";
import { cn } from "@/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>
);
}

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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}

View file

@ -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)}

View file

@ -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>

View file

@ -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">

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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>

View file

@ -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";

View file

@ -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";

View file

@ -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

View file

@ -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>
/>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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
? ` &quot;${escapeHtmlText(entry.quote)}&quot;`
: "";
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>
);
}

View file

@ -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>
);
}

View file

@ -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>
&ldquo;{query}&rdquo;{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>&nbsp;{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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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...";
}

View file

@ -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;
}

View file

@ -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));
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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";

View file

@ -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";

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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,
);
}

View file

@ -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>
</>
);
}

View file

@ -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,
);
}

View file

@ -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;

Some files were not shown because too many files have changed in this diff Show more