diff --git a/word-addin/.gitignore b/word-addin/.gitignore new file mode 100644 index 000000000..8ae55d044 --- /dev/null +++ b/word-addin/.gitignore @@ -0,0 +1,5 @@ +# Playwright E2E artifacts +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/word-addin/README.md b/word-addin/README.md new file mode 100644 index 000000000..6e8bec638 --- /dev/null +++ b/word-addin/README.md @@ -0,0 +1,211 @@ +# Mike Word Add-in + +An Office.js task pane add-in that brings the Mike legal AI platform directly into Microsoft Word. From the task pane you can chat with an AI about the open document (with optional full-document context), apply AI suggestions as tracked-change redlines, run one-click actions (improve writing, proofread, anonymise, draft clause), execute saved Mike workflows against the document, and browse or upload to Mike projects — all without leaving Word. + +The add-in talks to the **same API and Supabase project as the web app**: sign-in goes directly to Supabase (`/auth/v1/token`), while chat, actions, workflows, projects, and uploads call the Mike API (`http://localhost:3001` in local development). + +--- + +## Prerequisites + +- Node.js 22+ +- Microsoft Word desktop (macOS or Windows) **or** Word on the web — sideloading steps differ; see below +- The Mike API running locally (`npm run dev` from `backend/`) and Supabase configured per the root [README](../README.md) (`backend/.env` + `frontend/.env.local`) + +--- + +## Quick start (one command) + +If the API is already running and `frontend/.env.local` is filled in, this script does everything below for you — reads the Supabase URL + publishable key, writes `.env.development`, installs dependencies, installs the trusted dev certificate, and launches the add-in into Word: + +```bash +bash word-addin/scripts/dev.sh +``` + +It is idempotent (safe to re-run) and only prompts you when it genuinely needs input — namely the **keychain/admin password** when installing the dev HTTPS certificate the first time. After the cert installs, **fully quit Word (Cmd-Q)** and re-run the script so Word reloads the trust. + +The script verifies the backend before launching: + +- **Mike backend** — `GET /health` +- **Supabase** — `GET /auth/v1/health` + +If either is down it prints how to start them and **refuses to launch** (the task pane would just fail to sign in). Start the backend first: + +```bash +# from backend/ +npm run dev # the Mike API on :3001 +``` + +Flags: +- `--setup-only` — do everything except the final `npm start` (prep deps/env/cert; report backend status without launching). +- `FORCE=1 bash word-addin/scripts/dev.sh` — launch even if the backend check fails (sign-in won't work until Mike is up). + +The sections below explain each step the script automates, and the manual / web sideloading paths. + +--- + +## Setup (manual) + +1. **Install dependencies** + + ```bash + cd word-addin && npm install + ``` + +2. **Set environment variables** + + The webpack build reads these from `process.env` at compile time. Create a file called `.env.development` in `word-addin/`: + + ```bash + # word-addin/.env.development + REACT_APP_SUPABASE_URL=https://your-project.supabase.co + REACT_APP_SUPABASE_ANON_KEY= + REACT_APP_API_BASE_URL=http://localhost:3001 + ``` + + - `REACT_APP_SUPABASE_URL` / `REACT_APP_SUPABASE_ANON_KEY` — the same values as `frontend/.env.local`'s `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY` (from the Supabase dashboard). + - `REACT_APP_API_BASE_URL` — the Mike backend; default is `http://localhost:3001`. + + > **Mixed content / HTTPS:** Word serves the task pane over HTTPS (`https://localhost:3000`), and its WebView blocks plain-HTTP requests to the local backend. The `dev.sh` script avoids this by pointing the bundle at the dev server's same-origin HTTPS proxy (it sets the URLs to `https://localhost:3000` and proxies `/api` → `http://localhost:3001` and `/auth` etc. → Supabase). If you set the raw URLs above by hand, use `dev.sh` or replicate that proxy when testing in desktop Word. + + Because this is a custom webpack build (not Create React App), `.env.development` is **not** read automatically. Source it before running npm commands: + + ```bash + set -a && source .env.development && set +a + ``` + +3. **Trust the dev SSL certificate (one time only)** + + The dev server runs on `https://localhost:3000` with a self-signed certificate. Word refuses to load add-ins over untrusted HTTPS. Install the trusted cert once: + + ```bash + npx office-addin-dev-certs install + ``` + + Restart Word after installing. + +4. **Start the Mike backend** + + From the repo root: + + ```bash + (cd ../backend && npm run dev) + ``` + +5. **Start the add-in and sideload into Word** + + ```bash + npm start + ``` + + This runs `office-addin-debugging start manifest.xml`, which starts the webpack dev server on `https://localhost:3000` **and** automatically opens Word with the add-in sideloaded. The task pane appears under **Home → Mike Legal AI → Open Mike**. + +--- + +## Sideloading manually (if `npm start` does not auto-load) + +### Word desktop — macOS + +```bash +mkdir -p ~/Library/Containers/com.microsoft.Word/Data/Documents/wef +cp manifest.xml ~/Library/Containers/com.microsoft.Word/Data/Documents/wef/ +``` + +Restart Word, then: **Insert → Add-ins → My Add-ins → Mike** + +### Word on the web + +**Insert → Add-ins → Upload My Add-in** → select `manifest.xml` + +The manifest requires `WordApi 1.4`, which includes the change-tracking APIs. Word will not activate the add-in on a host that does not satisfy that requirement set. + +## Production build + +Production builds fail fast unless every service endpoint and the deployed add-in URL are explicit. This prevents publishing a bundle that silently calls localhost or has no Supabase key. + +```bash +cd word-addin +REACT_APP_API_BASE_URL=https://api.example.com \ +REACT_APP_SUPABASE_URL=https://example.supabase.co \ +REACT_APP_SUPABASE_ANON_KEY=... \ +REACT_APP_WEB_APP_URL=https://app.example.com \ +WORD_ADDIN_PUBLIC_URL=https://word.example.com \ +npm run build +``` + +The build writes the task-pane assets and a deployable, URL-rewritten manifest to `dist/`. The checked-in `manifest.xml` remains the localhost sideloading manifest. + +--- + +## Features + +### Chat tab + +Ask any question about the open document. Toggle **Use document as context** to send the full document text to the AI with each message (posted to the backend as `documentContext`, which the chat routes fence into the system prompt). Responses stream in real time. On any AI response you can: + +- **Insert below cursor** — inserts one or more real paragraphs after the paragraph containing the current selection; selected text is never overwritten +- **Insert below (tracked)** — performs the same paragraph-aware insertion with change tracking enabled, then restores the user's prior tracking mode + +### Actions tab + +One-click AI operations, each streaming their result into a result box: + +| Action | What it does | +|---|---| +| **Improve Writing** | Captures the exact selected range and rewrites it for clarity and professionalism. The result can replace that captured range with or without tracking. It never searches for and replaces a different duplicate elsewhere, and it refuses to apply if the selected range changed while the model was responding. | +| **Proofread** | Reviews the **entire document** for grammar, typos, punctuation, and stylistic issues. Lists each problem with the original text and a suggested correction. Result is read-only (review and copy manually). | +| **Anonymise** | Scans the **entire document** for PII (names, addresses, phone numbers, dates of birth, IDs, etc.) and produces a numbered list of occurrences with proposed anonymised replacements. Result is read-only. | +| **Draft Clause** | Enter a description of the clause you need, then click **Draft clause**. The result is normalised from model Markdown into Word paragraphs and can be inserted below the cursor with or without tracking. | + +### Workflows tab + +Select a saved Mike workflow from the dropdown and click **Run workflow on document**. The workflow instruction and document context are sent to the API. Results stream in and can be inserted as paragraphs below the cursor. + +### Projects tab + +Browse Mike projects you have access to. Selecting a project shows all documents currently in it. Click **Upload current document to project** to export the open Word document as a `.docx` file and upload it to the selected project via the Mike backend. + +--- + +## Signing in + +Enter the same email and password you use for the Mike web app. The add-in authenticates directly against Supabase (`/auth/v1/token`) and stores the access token in `OfficeRuntime.storage` (persists across task pane reloads). Click **Sign out** in the header to clear the token. + +--- + +## Tests + +The add-in ships a strict TypeScript check and a hermetic Playwright e2e suite that runs entirely against a mocked Office.js host and a stubbed backend — no Word, Supabase, or live backend required: + +```bash +cd word-addin +npm run typecheck +npm run build:e2e +npm run test:e2e +``` + +It builds the bundle with test env vars, serves it over plain HTTP, injects an Office.js mock (`e2e/support/office-mock.ts`), and drives every task-pane flow (auth, chat, actions, workflows, projects). + +--- + +## Troubleshooting + +**"Certificate not trusted" / blank white pane on load** +Run `npx office-addin-dev-certs install` from `word-addin/`, then fully quit and restart Word. + +**Add-in shows blank after the cert is trusted** +Right-click the task pane → **Inspect** and check the console for errors. A common cause is a missing or wrong `REACT_APP_SUPABASE_URL` / `REACT_APP_SUPABASE_ANON_KEY` — the bundle compiles with empty strings if the env vars were not exported before `npm start`. + +**Login fails with "Login failed" or a 401** +Confirm the `REACT_APP_SUPABASE_URL` / `REACT_APP_SUPABASE_ANON_KEY` in `.env.development` match `frontend/.env.local`, and that the URL has no trailing slash. + +**Tracked insertion is unavailable** +The add-in requires WordApi 1.4. Confirm the Word host and build support that requirement set; otherwise use a supported Microsoft 365 Word client. + +**Document upload fails** +- Confirm the Mike API is running (`npm run dev` in `backend/`) and reachable at `http://localhost:3001` +- Confirm the API's configured object-storage bucket exists +- Check the backend logs for the specific error + +**Workflows tab shows "No workflows found"** +Workflows are fetched from `GET /workflows` on the Mike backend. Confirm the backend is running and that at least one workflow exists in the database. diff --git a/word-addin/assets/icon-16.png b/word-addin/assets/icon-16.png new file mode 100644 index 000000000..8d2e795f5 Binary files /dev/null and b/word-addin/assets/icon-16.png differ diff --git a/word-addin/assets/icon-32.png b/word-addin/assets/icon-32.png new file mode 100644 index 000000000..9ee15f2f7 Binary files /dev/null and b/word-addin/assets/icon-32.png differ diff --git a/word-addin/assets/icon-80.png b/word-addin/assets/icon-80.png new file mode 100644 index 000000000..88153e869 Binary files /dev/null and b/word-addin/assets/icon-80.png differ diff --git a/word-addin/assets/logo-filled.png b/word-addin/assets/logo-filled.png new file mode 100644 index 000000000..973e12858 Binary files /dev/null and b/word-addin/assets/logo-filled.png differ diff --git a/word-addin/e2e/actions.spec.ts b/word-addin/e2e/actions.spec.ts new file mode 100644 index 000000000..32ce95cf3 --- /dev/null +++ b/word-addin/e2e/actions.spec.ts @@ -0,0 +1,410 @@ +/** + * E2E coverage for the Document Actions tab (DocumentActions.tsx). + * + * The tab exposes four AI actions, all streamed from POST /chat: + * 1. Improve Writing — rewrites the current SELECTION; on success offers + * exact captured-range replacement, tracked or untracked. + * 2. Proofread — reads the WHOLE document body, lists issues. + * 3. Anonymise — reads the WHOLE document body, lists PII replacements. + * 4. Draft Clause — drafts from a free-text prompt; offers "Insert at + * below cursor, tracked or untracked (insertParagraph after). + * + * Every test starts signed-in (seeded token) and lands on the Actions tab. + * The /chat SSE stream and document/selection state are mocked/seeded so the + * suite is fully hermetic and deterministic. + */ +import { test, expect } from "./support/fixtures"; +import type { Addin } from "./support/fixtures"; + +const TOKEN = "test-jwt"; + +/** Sign in (seeded token), open the task pane, switch to the Actions tab. */ +async function gotoActions( + addin: Addin, + opts: { documentText?: string; selectionText?: string } = {} +): Promise { + await addin.gotoTaskpane({ token: TOKEN, ...opts }); + await addin.expectAuthedShell(); + await addin.page.getByRole("tab", { name: "Actions" }).click(); + // Confirm the Actions panel mounted. + await expect( + addin.page.getByRole("button", { name: "Improve selected text" }) + ).toBeVisible(); +} + +// --------------------------------------------------------------------------- +// 1. Improve Writing +// --------------------------------------------------------------------------- +test.describe("Improve Writing", () => { + test("streams the rewrite of the selected text and offers apply options", async ({ + addin, + page, + }) => { + await addin.mockChatStream(["The parties ", "hereby agree."]); + await gotoActions(addin, { + selectionText: "the parties agree to this", + documentText: "Intro. the parties agree to this. Outro.", + }); + + await page.getByRole("button", { name: "Improve selected text" }).click(); + + await expect(page.getByText("The parties hereby agree.")).toBeVisible(); + // Both apply options surface once the stream finishes. + await expect( + page.getByRole("button", { name: "Replace selection (tracked)" }) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Replace selection", exact: true }) + ).toBeVisible(); + }); + + test("applies the improvement as a tracked change replacing the original", async ({ + addin, + page, + }) => { + const selection = "the parties agree to this"; + await addin.mockChatStream(["The parties hereby agree."]); + await gotoActions(addin, { + selectionText: selection, + documentText: `Recital. ${selection}. End.`, + }); + + await page.getByRole("button", { name: "Improve selected text" }).click(); + await expect(page.getByText("The parties hereby agree.")).toBeVisible(); + await page.getByRole("button", { name: "Replace selection (tracked)" }).click(); + + const calls = await addin.wordCalls(); + expect(calls.trackedChanges).toEqual([ + { + text: "The parties hereby agree.", + location: "Replace", + original: selection, + }, + ]); + expect(calls.changeTrackingMode).toBe("TrackAll"); + expect(calls.inserts).toEqual([]); + expect(calls.searches).toBe(0); + }); + + test("replaces the exact captured selection without tracking changes", async ({ + addin, + page, + }) => { + await addin.mockChatStream(["The parties hereby agree."]); + await gotoActions(addin, { + selectionText: "the parties agree to this", + documentText: "the parties agree to this", + }); + + await page.getByRole("button", { name: "Improve selected text" }).click(); + await expect(page.getByText("The parties hereby agree.")).toBeVisible(); + await page.getByRole("button", { name: "Replace selection", exact: true }).click(); + + const calls = await addin.wordCalls(); + expect(calls.inserts).toEqual([ + { + text: "The parties hereby agree.", + location: "Replace", + original: "the parties agree to this", + }, + ]); + expect(calls.trackedChanges).toEqual([]); + }); + + test("never substitutes a different duplicate elsewhere in the document", async ({ + addin, + page, + }) => { + const selection = "the Supplier shall comply"; + await addin.mockChatStream(["The Supplier must comply."]); + await gotoActions(addin, { + selectionText: selection, + documentText: `${selection}. Middle. ${selection}.`, + }); + + await page.getByRole("button", { name: "Improve selected text" }).click(); + await expect(page.getByText("The Supplier must comply.")).toBeVisible(); + await page + .getByRole("button", { name: "Replace selection (tracked)" }) + .click(); + + const calls = await addin.wordCalls(); + expect(calls.searches).toBe(0); + expect(calls.trackedChanges).toEqual([ + { + text: "The Supplier must comply.", + location: "Replace", + original: selection, + }, + ]); + }); + + test("refuses to overwrite a selection edited while the model was responding", async ({ + addin, + page, + }) => { + await addin.mockChatStream(["Improved wording."]); + await gotoActions(addin, { selectionText: "Original wording." }); + + await page.getByRole("button", { name: "Improve selected text" }).click(); + await expect(page.getByText("Improved wording.")).toBeVisible(); + await addin.setSelection("User changed this wording."); + await page + .getByRole("button", { name: "Replace selection (tracked)" }) + .click(); + + await expect( + page.getByText(/selected text changed while Mike was responding/i) + ).toBeVisible(); + const calls = await addin.wordCalls(); + expect(calls.inserts).toHaveLength(0); + expect(calls.trackedChanges).toHaveLength(0); + }); + + test("warns when no text is selected and never calls the model", async ({ + addin, + page, + }) => { + // Empty selection => early return before any /chat request. + await gotoActions(addin, { selectionText: " " }); + + await page.getByRole("button", { name: "Improve selected text" }).click(); + + await expect( + page.getByText("Please select some text first.") + ).toBeVisible(); + // No apply options because there is no improved text. + await expect( + page.getByRole("button", { name: "Replace selection (tracked)" }) + ).toHaveCount(0); + }); + + test("surfaces a streaming error message in the result box", async ({ + addin, + page, + }) => { + await addin.mockChatStream([], { errorBefore: "model is unavailable" }); + await gotoActions(addin, { selectionText: "rewrite me" }); + + await page.getByRole("button", { name: "Improve selected text" }).click(); + + await expect(page.getByText("model is unavailable")).toBeVisible(); + }); + + test("shows the loading indicator while the rewrite is in flight", async ({ + addin, + page, + }) => { + // Hanging /chat route keeps the action in its loading state deterministically. + await page.route("**/chat", async () => { + await new Promise(() => { + /* never resolves */ + }); + }); + await gotoActions(addin, { selectionText: "rewrite me" }); + + await page.getByRole("button", { name: "Improve selected text" }).click(); + + await expect(page.getByRole("button", { name: "Improving…" })).toBeVisible(); + await expect(page.getByText("Working…")).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Proofread +// --------------------------------------------------------------------------- +test.describe("Proofread", () => { + test("reads the whole document and streams the issue list", async ({ + addin, + page, + }) => { + await addin.mockChatStream([ + "1. 'agreement' should be capitalised.\n", + "2. Missing Oxford comma.", + ]); + await gotoActions(addin, { + documentText: "This agreement is governed by the laws of England.", + }); + + await page + .getByRole("button", { name: "Proofread entire document" }) + .click(); + + await expect( + page.getByText("'agreement' should be capitalised.") + ).toBeVisible(); + await expect(page.getByText("Missing Oxford comma.")).toBeVisible(); + }); + + test("surfaces a streaming error message", async ({ addin, page }) => { + await addin.mockChatStream([], { errorBefore: "proofread failed" }); + await gotoActions(addin, { documentText: "Some legal text." }); + + await page + .getByRole("button", { name: "Proofread entire document" }) + .click(); + + await expect(page.getByText("proofread failed")).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Anonymise +// --------------------------------------------------------------------------- +test.describe("Anonymise", () => { + test("lists PII replacements for the document", async ({ addin, page }) => { + await addin.mockChatStream([ + "1. John Smith -> [PARTY A]\n", + "2. 10 Downing Street -> [ADDRESS]", + ]); + await gotoActions(addin, { + documentText: "Signed by John Smith of 10 Downing Street.", + }); + + await page.getByRole("button", { name: "Find & list PII" }).click(); + + await expect(page.getByText("John Smith -> [PARTY A]")).toBeVisible(); + await expect( + page.getByText("10 Downing Street -> [ADDRESS]") + ).toBeVisible(); + }); + + test("surfaces a streaming error message", async ({ addin, page }) => { + await addin.mockChatStream([], { errorBefore: "pii scan failed" }); + await gotoActions(addin, { documentText: "Some legal text." }); + + await page.getByRole("button", { name: "Find & list PII" }).click(); + + await expect(page.getByText("pii scan failed")).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Draft Clause +// --------------------------------------------------------------------------- +test.describe("Draft Clause", () => { + test("disables the draft button until a prompt is entered", async ({ + addin, + page, + }) => { + await gotoActions(addin); + + await expect( + page.getByRole("button", { name: "Draft clause" }) + ).toBeDisabled(); + + await page + .getByPlaceholder("e.g. limitation of liability for SaaS product") + .fill("confidentiality clause"); + + await expect( + page.getByRole("button", { name: "Draft clause" }) + ).toBeEnabled(); + }); + + test("streams a drafted clause and offers insert options", async ({ + addin, + page, + }) => { + await addin.mockChatStream([ + "The Receiving Party shall ", + "keep all Confidential Information secret.", + ]); + await gotoActions(addin); + + await page + .getByPlaceholder("e.g. limitation of liability for SaaS product") + .fill("confidentiality clause"); + await page.getByRole("button", { name: "Draft clause" }).click(); + + await expect( + page.getByText( + "The Receiving Party shall keep all Confidential Information secret." + ) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Insert below cursor" }) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Insert below (tracked)" }) + ).toBeVisible(); + }); + + test("inserts the drafted clause at the cursor", async ({ addin, page }) => { + await addin.mockChatStream(["This is the drafted clause."]); + await gotoActions(addin); + + await page + .getByPlaceholder("e.g. limitation of liability for SaaS product") + .fill("indemnity clause"); + await page.getByRole("button", { name: "Draft clause" }).click(); + await expect(page.getByText("This is the drafted clause.")).toBeVisible(); + await page.getByRole("button", { name: "Insert below cursor" }).click(); + + const calls = await addin.wordCalls(); + expect(calls.inserts).toEqual([ + { text: "This is the drafted clause.", location: "After" }, + ]); + expect(calls.trackedChanges).toEqual([]); + }); + + test("applies the drafted clause as a tracked paragraph insertion", async ({ + addin, + page, + }) => { + await addin.mockChatStream(["This is the drafted clause."]); + await gotoActions(addin); + + await page + .getByPlaceholder("e.g. limitation of liability for SaaS product") + .fill("indemnity clause"); + await page.getByRole("button", { name: "Draft clause" }).click(); + await expect(page.getByText("This is the drafted clause.")).toBeVisible(); + await page.getByRole("button", { name: "Insert below (tracked)" }).click(); + + const calls = await addin.wordCalls(); + expect(calls.trackedChanges).toEqual([ + { text: "This is the drafted clause.", location: "After" }, + ]); + expect(calls.changeTrackingMode).toBe("TrackAll"); + expect(calls.inserts).toEqual([]); + }); + + test("normalises model Markdown into ordered Word paragraphs", async ({ + addin, + page, + }) => { + await addin.mockChatStream([ + "```markdown\n## Confidentiality\n\n- Keep **Information** confidential.\n- Notify [Acme](https://example.com).\n```", + ]); + await gotoActions(addin, { selectionText: "Existing paragraph." }); + + await page + .getByPlaceholder("e.g. limitation of liability for SaaS product") + .fill("confidentiality clause"); + await page.getByRole("button", { name: "Draft clause" }).click(); + await expect(page.getByText("Confidentiality")).toBeVisible(); + await page.getByRole("button", { name: "Insert below cursor" }).click(); + + const calls = await addin.wordCalls(); + expect(calls.inserts).toEqual([ + { text: "Confidentiality", location: "After" }, + { text: "", location: "After" }, + { text: "• Keep Information confidential.", location: "After" }, + { text: "• Notify Acme.", location: "After" }, + ]); + expect(calls.trackedChanges).toHaveLength(0); + }); + + test("surfaces a streaming error message", async ({ addin, page }) => { + await addin.mockChatStream([], { errorBefore: "draft failed" }); + await gotoActions(addin); + + await page + .getByPlaceholder("e.g. limitation of liability for SaaS product") + .fill("non-compete clause"); + await page.getByRole("button", { name: "Draft clause" }).click(); + + await expect(page.getByText("draft failed")).toBeVisible(); + }); +}); diff --git a/word-addin/e2e/api-key-banner.spec.ts b/word-addin/e2e/api-key-banner.spec.ts new file mode 100644 index 000000000..e6bfde2a9 --- /dev/null +++ b/word-addin/e2e/api-key-banner.spec.ts @@ -0,0 +1,90 @@ +/** + * ApiKeyBanner — the setup nudge shown when the signed-in user has no AI + * provider key configured (GET /user/api-keys reports every model provider + * false). Mirrors the web app's banner: session-dismissible, links out to the + * web app's account/api-keys page. + */ +import { test, expect } from "./support/fixtures"; + +const STATUS_GLOB = "**/user/api-keys"; +const BANNER_TEXT = "No AI provider key is set up."; + +const emptySources = { + claude: null, + gemini: null, + openai: null, + openrouter: null, + courtlistener: null, +}; + +const noneConfigured = { + claude: false, + gemini: false, + openai: false, + openrouter: false, + courtlistener: false, + sources: emptySources, +}; + +const claudeConfigured = { + ...noneConfigured, + claude: true, + sources: { ...emptySources, claude: "env" }, +}; + +test.describe("API key banner", () => { + test("shows the setup banner when no model provider key is configured", async ({ + addin, + page, + }) => { + addin.seedToken("test-access-token"); + await addin.mockApiJson("GET", STATUS_GLOB, noneConfigured); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + await expect(page.getByText(BANNER_TEXT)).toBeVisible(); + await expect( + page.getByRole("button", { name: "Set up API keys" }) + ).toBeVisible(); + }); + + test("stays hidden when a model provider key is configured", async ({ + addin, + page, + }) => { + addin.seedToken("test-access-token"); + await addin.mockApiJson("GET", STATUS_GLOB, claudeConfigured); + const statusFetched = page.waitForResponse(STATUS_GLOB); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + // Only assert after the status response has landed — before that the + // banner is hidden regardless, and the test would pass vacuously. + await statusFetched; + await expect(page.getByText(BANNER_TEXT)).toBeHidden(); + }); + + test("dismiss hides the banner", async ({ addin, page }) => { + addin.seedToken("test-access-token"); + await addin.mockApiJson("GET", STATUS_GLOB, noneConfigured); + await addin.gotoTaskpane(); + await expect(page.getByText(BANNER_TEXT)).toBeVisible(); + + await page.getByRole("button", { name: "Dismiss" }).click(); + await expect(page.getByText(BANNER_TEXT)).toBeHidden(); + }); + + test("stays hidden when the status endpoint errors", async ({ + addin, + page, + }) => { + addin.seedToken("test-access-token"); + await addin.mockApiError("GET", STATUS_GLOB, 500, "boom"); + const statusFetched = page.waitForResponse(STATUS_GLOB); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + await statusFetched; + await expect(page.getByText(BANNER_TEXT)).toBeHidden(); + }); +}); diff --git a/word-addin/e2e/auth.spec.ts b/word-addin/e2e/auth.spec.ts new file mode 100644 index 000000000..dbb8605a8 --- /dev/null +++ b/word-addin/e2e/auth.spec.ts @@ -0,0 +1,229 @@ +/** + * Auth flow E2E coverage for the Mike Word add-in. + * + * Exercises the real, user-visible behaviour of the login gate: + * - App.tsx loading spinner -> token gate -> LoginPage / tab shell / Sign out + * - auth/LoginPage.tsx submit-disabled gate + error alert + * - auth/useAuth.ts token persistence in OfficeRuntime.storage + * + * The Supabase password grant (POST **\/auth/v1/token**) is mocked via + * addin.mockLogin — no live backend is ever contacted. + */ +import { test, expect } from "./support/fixtures"; + +test.describe("auth flow", () => { + test("resolves the loading spinner into the login page when no token is stored", async ({ + addin, + page, + }) => { + await addin.gotoTaskpane(); + + // App.tsx shows only while the token is being + // read from storage; once useAuth resolves with no token it must give way + // to the LoginPage rather than getting stuck on the spinner. + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); + await expect(page.getByText("AI-powered legal assistant")).toBeVisible(); + await expect(page.getByText("Loading…")).toBeHidden(); + + // No app shell, no token. + await expect(page.getByRole("tab", { name: "Chat" })).toHaveCount(0); + expect(await addin.getToken()).toBeNull(); + }); + + test("Sign in stays disabled until both email and password are filled", async ({ + addin, + page, + }) => { + await addin.gotoTaskpane(); + + const signIn = page.getByRole("button", { name: "Sign in" }); + const email = page.getByRole("textbox", { name: "Email address" }); + const password = page.getByRole("textbox", { name: "Password" }); + + await expect(signIn).toBeDisabled(); + + await email.fill("lawyer@firm.com"); + await expect(signIn).toBeDisabled(); + + await password.fill("hunter2"); + await expect(signIn).toBeEnabled(); + + // Clearing either field re-disables the button. + await email.fill(""); + await expect(signIn).toBeDisabled(); + }); + + test("surfaces an error alert when credentials are rejected", async ({ + addin, + page, + }) => { + await addin.mockLogin({ error: "Invalid login credentials" }); + await addin.gotoTaskpane(); + + await page.getByRole("textbox", { name: "Email address" }).fill("wrong@firm.com"); + await page.getByRole("textbox", { name: "Password" }).fill("badpassword"); + await page.getByRole("button", { name: "Sign in" }).click(); + + // LoginPage renders the message from error_description with role="alert". + const alert = page.getByRole("alert"); + await expect(alert).toBeVisible(); + await expect(alert).toHaveText("Invalid login credentials"); + + // Failed login leaves the user on the login page with no token persisted. + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Chat" })).toHaveCount(0); + expect(await addin.getToken()).toBeNull(); + }); + + test("valid credentials persist the token and render the tab shell", async ({ + addin, + page, + }) => { + await addin.mockLogin({ ok: true, accessToken: "valid-jwt-123" }); + await addin.gotoTaskpane(); + + await page.getByRole("textbox", { name: "Email address" }).fill("lawyer@firm.com"); + await page.getByRole("textbox", { name: "Password" }).fill("correct-password"); + await page.getByRole("button", { name: "Sign in" }).click(); + + // Successful grant swaps the LoginPage for the authenticated 4-tab shell. + await addin.expectAuthedShell(); + await expect(page.getByRole("button", { name: "Sign in" })).toHaveCount(0); + + // Token is persisted into OfficeRuntime.storage under "mike_token". + expect(await addin.getToken()).toBe("valid-jwt-123"); + }); + + test("Sign out clears the token and returns to the login page", async ({ + addin, + page, + }) => { + addin.seedToken("seeded-jwt"); + await addin.gotoTaskpane(); + + // Pre-seeded token => app shell renders straight away. + await addin.expectAuthedShell(); + + await page.getByRole("button", { name: "Sign out" }).click(); + + // Logout drops the token and falls back to the LoginPage. + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Chat" })).toHaveCount(0); + expect(await addin.getToken()).toBeNull(); + }); + + test("a pre-seeded stored token renders the app shell immediately (persistence)", async ({ + addin, + page, + }) => { + addin.seedToken("persisted-jwt"); + await addin.gotoTaskpane(); + + // No login interaction needed — useAuth reads the stored token on mount and + // App.tsx renders the authenticated shell directly. + await addin.expectAuthedShell(); + await expect(page.getByRole("button", { name: "Sign in" })).toHaveCount(0); + expect(await addin.getToken()).toBe("persisted-jwt"); + }); +}); + +/** + * Token-refresh coverage for auth/session.ts. + * + * Supabase access tokens expire after ~1h, so a token persisted from an earlier + * session is reliably stale when Word is reopened — and every authenticated call + * then 401s with "Invalid or expired token". These tests pin the recovery + * behaviour: a stored refresh token transparently re-mints the access token and + * the original request is replayed; a refresh that fails drops back to login. + */ +test.describe("session refresh", () => { + test("a 401 triggers a refresh and replays the request with the new token", async ({ + addin, + page, + }) => { + // Start signed-in with a STALE access token plus a usable refresh token, + // exactly as a re-opened add-in would after the access token aged out. + addin.seedToken("stale-access"); + addin.seedRefreshToken("refresh-1"); + + // The refresh grant mints a new, rotated session. + await page.route("**/auth/v1/token**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + access_token: "fresh-access", + refresh_token: "refresh-2", + token_type: "bearer", + expires_in: 3600, + }), + }) + ); + + // /projects rejects the stale token and only accepts the refreshed one — + // proving the client refreshed AND retried rather than giving up on the 401. + await page.route("**/projects", (route, request) => { + const auth = request.headers()["authorization"] ?? ""; + if (auth === "Bearer fresh-access") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: "p1", name: "Alpha Matter" }]), + }); + } + return route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "Invalid or expired token" }), + }); + }); + await page.route("**/projects/p1/documents", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }) + ); + + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + await page.getByRole("tab", { name: "Projects" }).click(); + + // The list rendered, so the retried request succeeded. + await expect(page.getByRole("option", { name: "Alpha Matter" })).toBeAttached(); + + // The rotated tokens were persisted for subsequent calls. + expect(await addin.getToken()).toBe("fresh-access"); + expect(await addin.getRefreshToken()).toBe("refresh-2"); + }); + + test("a failed refresh clears the session and falls back to login", async ({ + addin, + page, + }) => { + addin.seedToken("stale-access"); + addin.seedRefreshToken("revoked-refresh"); + + // The refresh token is rejected (revoked/expired) — there is no recovery. + await page.route("**/auth/v1/token**", (route) => + route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ error: "invalid_grant" }), + }) + ); + // Every /projects call 401s; the refresh can't rescue it. + await page.route("**/projects", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "Invalid or expired token" }), + }) + ); + + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + await page.getByRole("tab", { name: "Projects" }).click(); + + // The dead session is cleared and the login gate returns. + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); + expect(await addin.getToken()).toBeNull(); + expect(await addin.getRefreshToken()).toBeNull(); + }); +}); diff --git a/word-addin/e2e/chat.spec.ts b/word-addin/e2e/chat.spec.ts new file mode 100644 index 000000000..e8c03e479 --- /dev/null +++ b/word-addin/e2e/chat.spec.ts @@ -0,0 +1,214 @@ +/** + * E2E coverage for the Chat flow (ChatPanel.tsx + api/stream.ts streamAssistant + * + hooks/useWordDoc.ts). + * + * Every test starts signed in (seeded token) so the authenticated shell renders + * with Chat as the default tab. The `/chat` SSE stream is mocked per test via + * the shared `addin.mockChatStream` helper; no live backend is ever contacted. + */ +import { test, expect } from "./support/fixtures"; + +const TOKEN = "test-jwt-token"; + +test.beforeEach(async ({ addin }) => { + // Authenticated session => app shell + Chat tab mount instead of LoginPage. + addin.seedToken(TOKEN); +}); + +test("shows the empty-state prompt before any message is sent", async ({ + addin, + page, +}) => { + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + await expect(page.getByText("Ask anything about your document")).toBeVisible(); + // No bubbles yet: the message list isn't rendered. + await expect(page.getByRole("button", { name: "Insert below cursor" })).toHaveCount( + 0 + ); +}); + +test("typing + Send streams an assistant bubble that concatenates content_delta chunks", async ({ + addin, + page, +}) => { + await addin.mockChatStream(["The contract ", "is ", "valid."]); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("Ask Mike…").fill("Summarize this document"); + await page.getByRole("button", { name: "Send" }).click(); + + // The user's message renders as its own bubble... + await expect(page.getByText("Summarize this document")).toBeVisible(); + // ...and the assistant bubble concatenates every chunk, stopping at [DONE]. + await expect(page.getByText("The contract is valid.")).toBeVisible(); + // Empty state is gone once messages exist. + await expect( + page.getByText("Ask anything about your document") + ).toHaveCount(0); +}); + +test("a pre-[DONE] error event surfaces as 'Error: ...' in the assistant bubble", async ({ + addin, + page, +}) => { + await addin.mockChatStream(["partial answer"], { + errorBefore: "model rate limited", + }); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("Ask Mike…").fill("Do something"); + await page.getByRole("button", { name: "Send" }).click(); + + // The client throws on the pre-[DONE] error; ChatPanel replaces the bubble + // content with the error message. + await expect(page.getByText("Error: model rate limited")).toBeVisible(); +}); + +test("'Use document as context' reads the document and includes documentContext in the request", async ({ + addin, + page, +}) => { + const docText = "This Agreement is governed by the laws of Delaware."; + await addin.mockChatStream(["ok"]); + await addin.gotoTaskpane({ documentText: docText }); + await addin.expectAuthedShell(); + + await page + .getByRole("switch", { name: "Use document as context" }) + .click(); + + await page.getByPlaceholder("Ask Mike…").fill("What law governs?"); + + const requestPromise = page.waitForRequest("**/chat"); + await page.getByRole("button", { name: "Send" }).click(); + const request = await requestPromise; + + const body = request.postDataJSON(); + expect(body.documentContext).toBe(docText); +}); + +test("the request omits documentContext when the context switch is off", async ({ + addin, + page, +}) => { + await addin.mockChatStream(["ok"]); + await addin.gotoTaskpane({ documentText: "Some document body text." }); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("Ask Mike…").fill("Hello"); + + const requestPromise = page.waitForRequest("**/chat"); + await page.getByRole("button", { name: "Send" }).click(); + const request = await requestPromise; + + const body = request.postDataJSON(); + expect(body.documentContext).toBeUndefined(); +}); + +test("'Insert below cursor' adds a paragraph without replacing the selection", async ({ + addin, + page, +}) => { + await addin.mockChatStream(["Insert me into the doc."]); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("Ask Mike…").fill("Draft a clause"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByText("Insert me into the doc.")).toBeVisible(); + + await page.getByRole("button", { name: "Insert below cursor" }).click(); + + await expect + .poll(async () => (await addin.wordCalls()).inserts.length) + .toBe(1); + const calls = await addin.wordCalls(); + expect(calls.inserts[0].text).toBe("Insert me into the doc."); + expect(calls.inserts[0].location).toBe("After"); + // A plain insert must NOT be recorded as a tracked change. + expect(calls.trackedChanges).toHaveLength(0); +}); + +test("'Insert below (tracked)' inserts a paragraph under track-changes ON", async ({ + addin, + page, +}) => { + await addin.mockChatStream(["Tracked suggestion text."]); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("Ask Mike…").fill("Suggest an edit"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByText("Tracked suggestion text.")).toBeVisible(); + + await page.getByRole("button", { name: "Insert below (tracked)" }).click(); + + await expect + .poll(async () => (await addin.wordCalls()).trackedChanges.length) + .toBe(1); + const calls = await addin.wordCalls(); + expect(calls.trackedChanges[0].text).toBe("Tracked suggestion text."); + expect(calls.trackedChanges[0].location).toBe("After"); + expect(calls.changeTrackingMode).toBe("TrackAll"); +}); + +test("Enter sends the message", async ({ addin, page }) => { + await addin.mockChatStream(["Replied via Enter."]); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + const input = page.getByPlaceholder("Ask Mike…"); + await input.fill("Send with Enter"); + await input.press("Enter"); + + await expect(page.getByText("Send with Enter")).toBeVisible(); + await expect(page.getByText("Replied via Enter.")).toBeVisible(); +}); + +test("Shift+Enter does not send the message", async ({ addin, page }) => { + await addin.mockChatStream(["should not appear"]); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + const input = page.getByPlaceholder("Ask Mike…"); + await input.fill("Draft line one"); + await input.press("Shift+Enter"); + + // No request fired => still empty state, input retains its text. The composer + // is a multi-line textarea, so Shift+Enter inserts a newline rather than + // sending — assert the typed text is preserved (a trailing newline is fine). + await expect( + page.getByText("Ask anything about your document") + ).toBeVisible(); + await expect(input).toHaveValue(/^Draft line one/); +}); + +test("the composer swaps Send for a Stop control while streaming, then restores", async ({ + addin, + page, +}) => { + // Hold the stream open so the streaming state is observable; release after + // the assertions. `holdMs` keeps the /chat response pending. + await addin.mockChatStream(["Slow streamed reply."], { holdMs: 1500 }); + await addin.gotoTaskpane(); + await addin.expectAuthedShell(); + + const input = page.getByPlaceholder("Ask Mike…"); + + await input.fill("Take your time"); + await page.getByRole("button", { name: "Send" }).click(); + + // While streaming: the textarea is disabled and Send is replaced by a + // reachable Stop control (previously the Stop button was dead code). + await expect(input).toBeDisabled(); + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Send" })).toHaveCount(0); + + // Once the stream finishes the input re-enables and Send returns. + await expect(input).toBeEnabled({ timeout: 5000 }); + await expect(page.getByRole("button", { name: "Send" })).toBeVisible(); +}); diff --git a/word-addin/e2e/projects.spec.ts b/word-addin/e2e/projects.spec.ts new file mode 100644 index 000000000..f1e6eab0f --- /dev/null +++ b/word-addin/e2e/projects.spec.ts @@ -0,0 +1,168 @@ +/** + * E2E coverage for the Projects flow — apps/word-addin/src/taskpane/components/ProjectPicker.tsx. + * + * The ProjectPicker (rendered under the "Projects" tab) is responsible for: + * - loading the project list GET /projects + * - selecting a project (native setEmail(e.target.value)} + placeholder="you@firm.com" + disabled={loading} + autoComplete="email" + required + /> + + +
+ + setPassword(e.target.value)} + placeholder="••••••••" + disabled={loading} + autoComplete="current-password" + required + /> +
+ + {error && ( +

+ {error} +

+ )} + + + + {guestEnabled && ( +
+ +

+ Local development only +

+
+ )} + + + + ); +} diff --git a/word-addin/src/taskpane/auth/session.ts b/word-addin/src/taskpane/auth/session.ts new file mode 100644 index 000000000..63a089fea --- /dev/null +++ b/word-addin/src/taskpane/auth/session.ts @@ -0,0 +1,334 @@ +/// +/** + * Single source of truth for the add-in's Supabase session. + * + * The task pane authenticates with Supabase's password grant and then calls the + * Mike API with the resulting JWT as a Bearer token. Those access tokens are + * short-lived (Supabase defaults to a one-hour expiry), so a token persisted in + * OfficeRuntime.storage during an earlier session is reliably expired by the + * time the user reopens Word — and EVERY authenticated call then fails with + * 401 "Invalid or expired token" (chat, projects, workflows, actions alike). + * The original implementation stored ONLY the access token and never refreshed + * it, so once that token aged out the session was wedged until a manual + * sign-out / sign-in. + * + * This module fixes that by persisting the refresh token alongside the access + * token and transparently exchanging it for a new access token when the current + * one is expired (proactively, before a request leaves) or rejected (reactively, + * when the API answers 401). Both the React auth hook (useAuth) and the bare + * API client (api/client.ts) obtain their token through here, so a refresh + * triggered by one is instantly visible to the other, and a refresh that + * genuinely fails clears the session and drops every view back to the login + * gate rather than looping on dead 401s. + */ + +const ACCESS_KEY = "mike_token"; +const REFRESH_KEY = "mike_refresh_token"; + +const SUPABASE_URL: string = process.env.REACT_APP_SUPABASE_URL ?? ""; +const SUPABASE_ANON_KEY: string = process.env.REACT_APP_SUPABASE_ANON_KEY ?? ""; + +// Mike API base — same var the API client uses. In dev the pane calls it over +// the HTTPS proxy (https://localhost:3000/api → :3001) to avoid mixed content. +const API_BASE_URL: string = + process.env.REACT_APP_API_BASE_URL ?? "http://localhost:3001"; + +// Refresh a little BEFORE the token's `exp` so an in-flight request can't race +// the expiry boundary (covers modest client/server clock skew too). +const EXPIRY_SKEW_SECONDS = 60; + +// --------------------------------------------------------------------------- +// Module-level shared state. Every useAuth() instance and the API client read +// through these, and broadcast() re-renders all subscribed hooks on change. +// --------------------------------------------------------------------------- + +let _accessToken: string | null = null; +let _refreshToken: string | null = null; +let _loading = true; +let _error: string | null = null; + +let _initialized = false; // guards the hook's one-time load + loading flip +let _loadPromise: Promise | null = null; // guards the storage read itself +let _refreshPromise: Promise | null = null; // de-dupes concurrent refreshes + +const _subscribers = new Set<() => void>(); + +function broadcast(): void { + _subscribers.forEach((fn) => fn()); +} + +export function subscribe(fn: () => void): () => void { + _subscribers.add(fn); + return () => { + _subscribers.delete(fn); + }; +} + +export interface SessionState { + token: string | null; + loading: boolean; + error: string | null; +} + +export function getSessionState(): SessionState { + return { token: _accessToken, loading: _loading, error: _error }; +} + +// --------------------------------------------------------------------------- +// Persistence helpers +// --------------------------------------------------------------------------- + +/** Read the persisted tokens into memory exactly once. */ +function ensureLoaded(): Promise { + if (!_loadPromise) { + _loadPromise = Promise.all([ + OfficeRuntime.storage.getItem(ACCESS_KEY), + OfficeRuntime.storage.getItem(REFRESH_KEY), + ]) + .then(([access, refresh]) => { + _accessToken = access ?? null; + _refreshToken = refresh ?? null; + }) + .catch(() => { + _accessToken = null; + _refreshToken = null; + }); + } + return _loadPromise; +} + +/** Persist a freshly minted session (login or refresh) and notify subscribers. */ +async function writeSession( + access: string, + refresh: string | null +): Promise { + _accessToken = access; + _refreshToken = refresh; + await OfficeRuntime.storage.setItem(ACCESS_KEY, access).catch(() => {}); + if (refresh) { + await OfficeRuntime.storage.setItem(REFRESH_KEY, refresh).catch(() => {}); + } else { + await OfficeRuntime.storage.removeItem(REFRESH_KEY).catch(() => {}); + } + broadcast(); +} + +/** Drop the session from memory + storage and notify subscribers. */ +async function clearSession(): Promise { + _accessToken = null; + _refreshToken = null; + await OfficeRuntime.storage.removeItem(ACCESS_KEY).catch(() => {}); + await OfficeRuntime.storage.removeItem(REFRESH_KEY).catch(() => {}); + broadcast(); +} + +// --------------------------------------------------------------------------- +// JWT expiry inspection +// --------------------------------------------------------------------------- + +/** Decode a JWT's `exp` (seconds since epoch), or null if it isn't a JWT. */ +function decodeExp(token: string): number | null { + try { + const payload = token.split(".")[1]; + if (!payload) return null; + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const json = decodeURIComponent( + atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + const exp = (JSON.parse(json) as { exp?: number }).exp; + return typeof exp === "number" ? exp : null; + } catch { + return null; + } +} + +/** + * True when `token` is a JWT whose `exp` has passed (minus a safety skew). + * Non-JWT or undecodable tokens return false — we can't prove they're stale, so + * we let them go and rely on the reactive 401 path to catch a real rejection. + */ +function isExpired(token: string): boolean { + const exp = decodeExp(token); + if (exp == null) return false; + return Date.now() / 1000 >= exp - EXPIRY_SKEW_SECONDS; +} + +// --------------------------------------------------------------------------- +// Token acquisition +// --------------------------------------------------------------------------- + +/** + * Return a usable access token for an outgoing API request, refreshing first if + * the current one has expired. May return null (logged out / refresh failed), + * in which case the request will 401 and the reactive path takes over. + */ +export async function getFreshAccessToken(): Promise { + await ensureLoaded(); + if (_accessToken && !isExpired(_accessToken)) return _accessToken; + if (_refreshToken) { + const refreshed = await refreshSession(); + if (refreshed) return refreshed; + } + return _accessToken; +} + +/** + * Exchange the refresh token for a new access token. Concurrent callers share a + * single in-flight request. On a definitive failure (no refresh token, or the + * grant is rejected) the session is cleared so the UI returns to login; a + * transient network error leaves the session intact so a later retry can work. + */ +export function refreshSession(): Promise { + if (!_refreshPromise) { + _refreshPromise = doRefresh().finally(() => { + _refreshPromise = null; + }); + } + return _refreshPromise; +} + +async function doRefresh(): Promise { + await ensureLoaded(); + if (!_refreshToken) { + // Nothing to refresh with (e.g. a pre-refresh-era stored token). Force a + // clean re-login rather than spinning on 401s. + await clearSession(); + return null; + } + + let res: Response; + try { + res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: SUPABASE_ANON_KEY, + }, + body: JSON.stringify({ refresh_token: _refreshToken }), + }); + } catch { + // Network blip — keep the session and let the caller surface the failure. + return null; + } + + if (!res.ok) { + // The refresh token itself is invalid/expired/revoked: log out. + await clearSession(); + return null; + } + + const data = (await res.json().catch(() => ({}))) as { + access_token?: string; + refresh_token?: string; + }; + if (!data.access_token) { + await clearSession(); + return null; + } + + // Supabase rotates refresh tokens — persist the new one (falling back to the + // existing token if the response omitted it). + await writeSession(data.access_token, data.refresh_token ?? _refreshToken); + return data.access_token; +} + +// --------------------------------------------------------------------------- +// React-hook-facing lifecycle + auth actions +// --------------------------------------------------------------------------- + +/** Kick off the one-time storage read, flipping `loading` false when done. */ +export function initialize(): void { + if (_initialized) return; + _initialized = true; + void ensureLoaded().then(() => { + _loading = false; + broadcast(); + }); +} + +export async function signIn(email: string, password: string): Promise { + _loading = true; + _error = null; + broadcast(); + + try { + const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: SUPABASE_ANON_KEY, + }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error_description?: string; + message?: string; + error?: string; + }; + throw new Error( + body.error_description ?? body.message ?? body.error ?? "Login failed" + ); + } + + const data = (await res.json()) as { + access_token: string; + refresh_token?: string; + }; + await writeSession(data.access_token, data.refresh_token ?? null); + _loading = false; + _error = null; + broadcast(); + } catch (e) { + _loading = false; + _error = e instanceof Error ? e.message : "Login failed"; + broadcast(); + } +} + +/** + * Sign in as an ephemeral guest (local development only). Mirrors the web app: + * POST {API}/auth/guest returns a Supabase session which we persist like a + * normal login. The endpoint is gated to non-production on the server too. + */ +export async function signInAsGuest(): Promise { + _loading = true; + _error = null; + broadcast(); + + try { + const res = await fetch(`${API_BASE_URL}/auth/guest`, { method: "POST" }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + detail?: string; + message?: string; + }; + throw new Error( + body.detail ?? body.message ?? "Guest login is unavailable" + ); + } + + const data = (await res.json()) as { + access_token: string; + refresh_token?: string; + }; + await writeSession(data.access_token, data.refresh_token ?? null); + _loading = false; + _error = null; + broadcast(); + } catch (e) { + _loading = false; + _error = e instanceof Error ? e.message : "Guest login failed"; + broadcast(); + } +} + +export async function signOut(): Promise { + _error = null; + await clearSession(); +} diff --git a/word-addin/src/taskpane/auth/useAuth.ts b/word-addin/src/taskpane/auth/useAuth.ts new file mode 100644 index 000000000..8e76557bf --- /dev/null +++ b/word-addin/src/taskpane/auth/useAuth.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from "react"; +import { + getSessionState, + initialize, + signIn, + signInAsGuest, + signOut, + subscribe, +} from "./session"; + +// --------------------------------------------------------------------------- +// Thin React binding over the shared session store (auth/session.ts). All token +// state — including refresh-token handling — lives in that module so the bare +// API client can share it; this hook just subscribes mounted components to it. +// --------------------------------------------------------------------------- + +export interface AuthState { + token: string | null; + loading: boolean; + error: string | null; + login: (email: string, password: string) => Promise; + loginAsGuest: () => Promise; + logout: () => Promise; +} + +export function useAuth(): AuthState { + // Counter-based forceUpdate: the session store, not local state, is the + // source of truth — we just re-render when it broadcasts a change. + const [, rerender] = useState(0); + + useEffect(() => { + const unsubscribe = subscribe(() => rerender((n) => n + 1)); + initialize(); + return unsubscribe; + }, []); + + const login = useCallback( + (email: string, password: string) => signIn(email, password), + [] + ); + const loginAsGuest = useCallback(() => signInAsGuest(), []); + const logout = useCallback(() => signOut(), []); + + const { token, loading, error } = getSessionState(); + return { token, loading, error, login, loginAsGuest, logout }; +} diff --git a/word-addin/src/taskpane/components/ApiKeyBanner.tsx b/word-addin/src/taskpane/components/ApiKeyBanner.tsx new file mode 100644 index 000000000..c532c0029 --- /dev/null +++ b/word-addin/src/taskpane/components/ApiKeyBanner.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from "react"; +import { KeyRound, X } from "lucide-react"; +import { getApiKeyStatus, type ApiKeyStatus } from "../api/mikeApi"; +import { API_KEY_PROVIDERS } from "@mike/core"; + +const DISMISS_KEY = "apiKeyBannerDismissed"; + +// Providers that back a chat model — mirrors the web app's ApiKeyBanner. If +// none is configured the backend can't answer for real, so every chat/action +// fails with an authentication error. @mike/core's API_KEY_PROVIDERS also +// lists "openrouter" and "courtlistener", which this banner intentionally omits +// (courtlistener is a case-law search key, not a chat model; openrouter was +// never surfaced here) — filter to the chat providers the banner has always +// checked so its show/hide behaviour is unchanged. +const MODEL_PROVIDERS = API_KEY_PROVIDERS.filter( + (p) => p === "claude" || p === "gemini" || p === "openai" +); + +// The web app hosts the API-keys settings page; the task pane only links to it. +// Guard `process` like client.ts does — a stale dev server can leave the +// substitution unapplied, and bare `process` throws in the browser. +const WEB_APP_URL: string = + (typeof process !== "undefined" && process.env.REACT_APP_WEB_APP_URL) || + "http://localhost:3000"; + +const API_KEYS_PAGE_URL = `${WEB_APP_URL.replace(/\/+$/, "")}/account/api-keys`; + +/** + * Open the web app's API-keys page in the system browser. Office's + * openBrowserWindow is the sanctioned way out of the task-pane webview + * (window.open is blocked in some hosts); fall back to window.open when the + * API isn't available (e.g. the hermetic e2e bundle or older hosts). + */ +function openApiKeysPage(): void { + const ui = + typeof Office !== "undefined" ? Office.context?.ui : undefined; + if (ui && typeof ui.openBrowserWindow === "function") { + ui.openBrowserWindow(API_KEYS_PAGE_URL); + } else { + window.open(API_KEYS_PAGE_URL, "_blank", "noopener,noreferrer"); + } +} + +function isDismissed(): boolean { + try { + return sessionStorage.getItem(DISMISS_KEY) === "true"; + } catch { + return false; + } +} + +/** + * Banner shown under the header when the signed-in user has no AI provider + * key configured (neither a platform env key nor a personal key). Without one, + * every chat/action errors out ("invalid x-api-key"), so we nudge towards the + * web app's API-keys page instead of letting the user discover it per-request. + * + * Only renders on a POSITIVE "no key" answer from the backend — while loading, + * or if the status call fails, nothing is shown (the per-request error paths + * already cover that). Dismissible for the session, like the web banner. + */ +export function ApiKeyBanner(): React.ReactElement | null { + const [missingKey, setMissingKey] = useState(false); + const [dismissed, setDismissed] = useState(isDismissed); + + useEffect(() => { + let cancelled = false; + getApiKeyStatus() + .then((status: ApiKeyStatus) => { + if (cancelled) return; + const anyConfigured = MODEL_PROVIDERS.some( + (provider) => status[provider] === true + ); + setMissingKey(!anyConfigured); + }) + .catch(() => { + // Unknown status — stay hidden rather than nag on a network blip. + }); + return () => { + cancelled = true; + }; + }, []); + + if (!missingKey || dismissed) return null; + + const handleDismiss = (): void => { + try { + sessionStorage.setItem(DISMISS_KEY, "true"); + } catch { + // Storage unavailable — dismiss still applies for this mount. + } + setDismissed(true); + }; + + return ( +
+
+ +

+ No AI provider key is set up.{" "} + + Chat and actions will fail until you add one. + +

+ +
+ +
+ ); +} diff --git a/word-addin/src/taskpane/components/ChatPanel.tsx b/word-addin/src/taskpane/components/ChatPanel.tsx new file mode 100644 index 000000000..7f9297ae8 --- /dev/null +++ b/word-addin/src/taskpane/components/ChatPanel.tsx @@ -0,0 +1,203 @@ +import React, { useState, useRef, useEffect } from "react"; +import { MessageSquareText } from "lucide-react"; +import { streamAssistant } from "../api/stream"; +import { useWordDoc } from "../hooks/useWordDoc"; +import { UserBubble, AssistantBubble } from "@mike/shared/chat/ChatBubble"; +import { ChatInput } from "@mike/shared/chat/ChatInput"; +import { Button } from "@mike/shared/ui/button"; +import { Switch } from "@mike/shared/ui/switch"; +import { Spinner } from "@mike/shared/ui/spinner"; + +interface Message { + role: "user" | "assistant"; + content: string; +} + +export function ChatPanel(): React.ReactElement { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [streaming, setStreaming] = useState(false); + const [useDocContext, setUseDocContext] = useState(false); + const listRef = useRef(null); + const abortRef = useRef(null); + const mountedRef = useRef(true); + const { readDocumentText, insertBelowSelection } = useWordDoc(); + + // Auto-scroll on new content + useEffect(() => { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [messages, streaming]); + + // Abort any in-flight stream when the panel unmounts (e.g. switching tabs) so + // we neither keep the connection open nor setState on an unmounted component. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + abortRef.current?.abort(); + }; + }, []); + + const handleCancel = (): void => abortRef.current?.abort(); + + const handleSend = async (): Promise => { + const text = input.trim(); + if (!text || streaming) return; + + let documentContext: string | undefined; + if (useDocContext) { + try { + documentContext = await readDocumentText(); + } catch { + documentContext = undefined; + } + } + + const userMsg: Message = { role: "user", content: text }; + const history: Message[] = [...messages, userMsg]; + + setMessages(history); + setInput(""); + setStreaming(true); + + // Append empty assistant slot so the user sees it filling in + const withPlaceholder: Message[] = [ + ...history, + { role: "assistant", content: "" }, + ]; + setMessages(withPlaceholder); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + await streamAssistant( + { messages: history, documentContext, signal: controller.signal }, + (chunk) => { + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last && last.role === "assistant") { + next[next.length - 1] = { + ...last, + content: last.content + chunk, + }; + } + return next; + }); + } + ); + } catch (e) { + // A user-initiated stop or an unmount aborts the request — keep whatever + // partial answer streamed in, don't render it as an error. + if (controller.signal.aborted || !mountedRef.current) return; + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last && last.role === "assistant") { + next[next.length - 1] = { + ...last, + content: + e instanceof Error ? `Error: ${e.message}` : "An error occurred.", + }; + } + return next; + }); + } finally { + if (abortRef.current === controller) abortRef.current = null; + if (mountedRef.current) setStreaming(false); + } + }; + + const hasMessages = messages.length > 0; + + return ( +
+ {/* Message list */} + {!hasMessages && !streaming ? ( +
+
+ +
+
+

+ Ask anything about your document +

+

+ Mike can summarize, explain, and draft — toggle document context + below to ground answers in your file. +

+
+
+ ) : ( +
+ {messages.map((msg, i) => + msg.role === "user" ? ( + + ) : ( + + + + + ) : undefined + } + /> + ) + )} + {streaming && ( +
+ + Thinking… +
+ )} +
+ )} + + {/* Composer */} +
+ void handleSend()} + isLoading={streaming} + onCancel={handleCancel} + disabled={streaming} + placeholder="Ask Mike…" + leftSlot={ + + } + /> +
+
+ ); +} diff --git a/word-addin/src/taskpane/components/DocumentActions.tsx b/word-addin/src/taskpane/components/DocumentActions.tsx new file mode 100644 index 000000000..58484cac9 --- /dev/null +++ b/word-addin/src/taskpane/components/DocumentActions.tsx @@ -0,0 +1,422 @@ +import React, { useState, useRef, useEffect } from "react"; +import { + Wand2, + SpellCheck, + EyeOff, + PenLine, + type LucideIcon, +} from "lucide-react"; +import { streamAssistant } from "../api/stream"; +import { useWordDoc } from "../hooks/useWordDoc"; +import type { WordSelectionAnchor } from "../hooks/useWordDoc"; +import { Button } from "@mike/shared/ui/button"; +import { Input } from "@mike/shared/ui/input"; +import { Label } from "@mike/shared/ui/label"; +import { Spinner } from "@mike/shared/ui/spinner"; + +interface ActionSectionState { + loading: boolean; + result: string; + originalText?: string; + // True when `result` holds an error message rather than usable output, so the + // Insert / Apply buttons must not be offered over it. + error?: boolean; +} + +const emptySection = (): ActionSectionState => ({ + loading: false, + result: "", + originalText: undefined, + error: false, +}); + +// Cap document text folded into a prompt so a large file can't blow past the +// model context / token budget (the backend also caps this defensively). +const MAX_DOC_CHARS = 200_000; + +function ResultBox({ children }: { children: React.ReactNode }): React.ReactElement { + return ( +
+ {children} +
+ ); +} + +function Section({ + title, + description, + icon: Icon, + children, +}: { + title: string; + description: string; + icon: LucideIcon; + children: React.ReactNode; +}): React.ReactElement { + return ( +
+
+
+ +
+
+

{title}

+

+ {description} +

+
+
+ {children} +
+ ); +} + +export function DocumentActions(): React.ReactElement { + const { + readDocumentText, + captureSelection, + releaseSelection, + replaceSelection, + insertBelowSelection, + } = useWordDoc(); + + const [improve, setImprove] = useState(emptySection()); + const [proof, setProof] = useState(emptySection()); + const [anon, setAnon] = useState(emptySection()); + const [draft, setDraft] = useState(emptySection()); + const [draftPrompt, setDraftPrompt] = useState(""); + const [applyError, setApplyError] = useState(null); + const improveAnchorRef = useRef(null); + + // Track mount + in-flight streams so switching tabs mid-action aborts the + // request and never calls setState on an unmounted component. + const mountedRef = useRef(true); + const controllersRef = useRef>(new Set()); + useEffect(() => { + mountedRef.current = true; + const controllers = controllersRef.current; + return () => { + mountedRef.current = false; + controllers.forEach((c) => c.abort()); + controllers.clear(); + const anchor = improveAnchorRef.current; + improveAnchorRef.current = null; + if (anchor) void releaseSelection(anchor); + }; + }, []); + + // ------------------------------------------------------------------ + // 1. Improve Writing + // ------------------------------------------------------------------ + const handleImproveWriting = async (): Promise => { + setApplyError(null); + setImprove({ loading: true, result: "" }); + const controller = new AbortController(); + controllersRef.current.add(controller); + try { + const previousAnchor = improveAnchorRef.current; + improveAnchorRef.current = null; + if (previousAnchor) await releaseSelection(previousAnchor); + + const anchor = await captureSelection(); + const selected = anchor.originalText; + if (!selected.trim()) { + await releaseSelection(anchor); + setImprove({ loading: false, result: "Please select some text first." }); + return; + } + improveAnchorRef.current = anchor; + const originalText = selected; + const prompt = `Rewrite the following selected legal text to improve clarity and professionalism while preserving its meaning. Preserve the number and order of paragraphs. Return only replacement text: no introduction, quotation marks, Markdown, or code fences.\n\n${selected}`; + let accumulated = ""; + await streamAssistant( + { + messages: [{ role: "user", content: prompt }], + signal: controller.signal, + }, + (chunk) => { + accumulated += chunk; + if (mountedRef.current) + setImprove({ loading: true, result: accumulated, originalText }); + } + ); + if (mountedRef.current) + setImprove({ loading: false, result: accumulated, originalText }); + } catch (e) { + if (controller.signal.aborted || !mountedRef.current) return; + const anchor = improveAnchorRef.current; + improveAnchorRef.current = null; + if (anchor) await releaseSelection(anchor); + setImprove({ + loading: false, + result: e instanceof Error ? e.message : "Error occurred.", + error: true, + }); + } finally { + controllersRef.current.delete(controller); + } + }; + + // ------------------------------------------------------------------ + // 2. Proofread + // ------------------------------------------------------------------ + const handleProofread = async (): Promise => { + setProof({ loading: true, result: "" }); + const controller = new AbortController(); + controllersRef.current.add(controller); + try { + const docText = (await readDocumentText()).slice(0, MAX_DOC_CHARS); + const prompt = `Proofread the following legal document. List every grammatical error, typo, punctuation issue, and stylistic inconsistency. For each issue, state the original text and your suggested correction:\n\n${docText}`; + let accumulated = ""; + await streamAssistant( + { + messages: [{ role: "user", content: prompt }], + signal: controller.signal, + }, + (chunk) => { + accumulated += chunk; + if (mountedRef.current) setProof({ loading: true, result: accumulated }); + } + ); + if (mountedRef.current) setProof({ loading: false, result: accumulated }); + } catch (e) { + if (controller.signal.aborted || !mountedRef.current) return; + setProof({ + loading: false, + result: e instanceof Error ? e.message : "Error occurred.", + error: true, + }); + } finally { + controllersRef.current.delete(controller); + } + }; + + // ------------------------------------------------------------------ + // 3. Anonymise + // ------------------------------------------------------------------ + const handleAnonymise = async (): Promise => { + setAnon({ loading: true, result: "" }); + const controller = new AbortController(); + controllersRef.current.add(controller); + try { + const docText = (await readDocumentText()).slice(0, MAX_DOC_CHARS); + const prompt = `Identify all personally identifiable information (PII) in the following document — names, addresses, phone numbers, email addresses, dates of birth, identification numbers, and any other identifying information. For each occurrence, list: (1) the original text, and (2) an anonymised replacement. Present as a numbered list:\n\n${docText}`; + let accumulated = ""; + await streamAssistant( + { + messages: [{ role: "user", content: prompt }], + signal: controller.signal, + }, + (chunk) => { + accumulated += chunk; + if (mountedRef.current) setAnon({ loading: true, result: accumulated }); + } + ); + if (mountedRef.current) setAnon({ loading: false, result: accumulated }); + } catch (e) { + if (controller.signal.aborted || !mountedRef.current) return; + setAnon({ + loading: false, + result: e instanceof Error ? e.message : "Error occurred.", + error: true, + }); + } finally { + controllersRef.current.delete(controller); + } + }; + + // ------------------------------------------------------------------ + // 4. Draft Clause + // ------------------------------------------------------------------ + const handleDraftClause = async (): Promise => { + if (!draftPrompt.trim()) return; + setDraft({ loading: true, result: "" }); + const controller = new AbortController(); + controllersRef.current.add(controller); + try { + const prompt = `Draft a professional legal clause for the following purpose. Output only the clause text, ready to be inserted into a contract:\n\n${draftPrompt}`; + let accumulated = ""; + await streamAssistant( + { + messages: [{ role: "user", content: prompt }], + signal: controller.signal, + }, + (chunk) => { + accumulated += chunk; + if (mountedRef.current) setDraft({ loading: true, result: accumulated }); + } + ); + if (mountedRef.current) setDraft({ loading: false, result: accumulated }); + } catch (e) { + if (controller.signal.aborted || !mountedRef.current) return; + setDraft({ + loading: false, + result: e instanceof Error ? e.message : "Error occurred.", + error: true, + }); + } finally { + controllersRef.current.delete(controller); + } + }; + + const applyRewrite = async (tracked: boolean): Promise => { + setApplyError(null); + const anchor = improveAnchorRef.current; + if (!anchor) return; + + try { + const result = await replaceSelection(anchor, improve.result, tracked); + improveAnchorRef.current = null; + await releaseSelection(anchor); + setImprove((current) => ({ + ...current, + originalText: undefined, + })); + + if (result === "stale") { + setApplyError( + "The selected text changed while Mike was responding. Select it again and rerun the rewrite." + ); + } + } catch (error) { + setApplyError( + error instanceof Error ? error.message : "Word couldn't apply the rewrite." + ); + } + }; + + return ( +
+ {/* --- Improve Writing --- */} +
+ + {improve.loading && } + {improve.result && ( + <> + {improve.result} + {!improve.loading && improve.originalText && !improve.error && ( + <> +
+ + +
+ + )} + {applyError && ( +

+ {applyError} +

+ )} + + )} +
+ + {/* --- Proofread --- */} +
+ + {proof.loading && } + {proof.result && {proof.result}} +
+ + {/* --- Anonymise --- */} +
+ + {anon.loading && } + {anon.result && {anon.result}} +
+ + {/* --- Draft Clause --- */} +
+
+ + setDraftPrompt(e.target.value)} + placeholder="e.g. limitation of liability for SaaS product" + disabled={draft.loading} + /> +
+ + {draft.loading && } + {draft.result && ( + <> + {draft.result} + {!draft.loading && !draft.error && ( +
+ + +
+ )} + + )} +
+
+ ); +} diff --git a/word-addin/src/taskpane/components/ProjectPicker.tsx b/word-addin/src/taskpane/components/ProjectPicker.tsx new file mode 100644 index 000000000..2256333b6 --- /dev/null +++ b/word-addin/src/taskpane/components/ProjectPicker.tsx @@ -0,0 +1,235 @@ +/// +import React, { useState, useEffect } from "react"; +import { FolderOpen, FileText, AlertCircle, CheckCircle2 } from "lucide-react"; +import { + listProjects, + listProjectDocuments, + uploadProjectDocument, +} from "../api/mikeApi"; +import type { Project, Document } from "@mike/core"; +import { useWordDoc } from "../hooks/useWordDoc"; +import { Button } from "@mike/shared/ui/button"; +import { Label } from "@mike/shared/ui/label"; +import { Spinner } from "@mike/shared/ui/spinner"; +import { Select } from "@mike/shared/ui/select"; + +export function ProjectPicker(): React.ReactElement { + const [projects, setProjects] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(true); + const [projectsError, setProjectsError] = useState(null); + + const [selectedProjectId, setSelectedProjectId] = useState(""); + // GET /projects/:id/documents returns `Document` rows (which expose + // `filename`, not `name`); we only read id + filename here. + const [docs, setDocs] = useState([]); + const [loadingDocs, setLoadingDocs] = useState(false); + const [docsError, setDocsError] = useState(null); + + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [uploadSuccess, setUploadSuccess] = useState(false); + + const { getDocxBlob } = useWordDoc(); + + // Load project list on mount + useEffect(() => { + listProjects() + .then((data) => { + setProjects(data); + if (data.length > 0) setSelectedProjectId(data[0].id); + }) + .catch((e: unknown) => { + setProjectsError( + e instanceof Error ? e.message : "Failed to load projects" + ); + }) + .finally(() => setLoadingProjects(false)); + }, []); + + // Reload document list when selected project changes + useEffect(() => { + if (!selectedProjectId) { + setDocs([]); + return; + } + // `ignore` discards a slow response after the selection changed again (or + // the component unmounted), so a stale project's docs can't overwrite the + // current ones (request race). + let ignore = false; + setLoadingDocs(true); + setDocsError(null); + setDocs([]); + listProjectDocuments(selectedProjectId) + .then((d) => { + if (!ignore) setDocs(d); + }) + .catch((e: unknown) => { + // Surface the failure instead of masking a 500 as the "no documents" + // empty state. + if (!ignore) + setDocsError( + e instanceof Error ? e.message : "Failed to load documents" + ); + }) + .finally(() => { + if (!ignore) setLoadingDocs(false); + }); + return () => { + ignore = true; + }; + }, [selectedProjectId]); + + const handleUpload = async (): Promise => { + if (!selectedProjectId) return; + setUploading(true); + setUploadError(null); + setUploadSuccess(false); + + try { + // Retrieve the real binary .docx (ZIP archive) rather than raw XML + const blob = await getDocxBlob(); + + // Derive a filename from the document URL or fall back to a default. + // getFileAsync(Compressed) always returns OOXML (.docx) bytes regardless + // of the on-disk format, so the upload must carry a .docx extension — the + // API validates extension against magic bytes and rejects e.g. a ZIP sent + // as ".doc". Strip any query string and force the .docx extension. + const rawUrl = Office.context.document.url ?? ""; + const base = + rawUrl + .split(/[\\/]/) + .pop() + ?.split("?")[0] + ?.replace(/\.[^.]+$/, "") + ?.trim() || "document"; + const fileName = `${base}.docx`; + + // Wrap the blob in a File so the shared uploadProjectDocument() sends the + // .docx filename in the multipart part (the API validates the extension + // against the magic bytes). The client handles auth + the multipart + // Content-Type/boundary; an expired token is refreshed transparently. + const file = new File([blob], fileName, { type: blob.type }); + await uploadProjectDocument(selectedProjectId, file); + + setUploadSuccess(true); + + // Refresh document list + const updated = await listProjectDocuments(selectedProjectId); + setDocs(updated); + } catch (e) { + setUploadError(e instanceof Error ? e.message : "Upload failed."); + } finally { + setUploading(false); + } + }; + + if (loadingProjects) { + return ( +
+ +
+ ); + } + + if (projectsError) { + return ( +
+ +

{projectsError}

+
+ ); + } + + if (projects.length === 0) { + return ( +
+
+ +
+

No projects found.

+

+ Create a project in the Mike web app to upload documents to it. +

+
+ ); + } + + return ( +
+ {/* Project selector */} +
+ + +
+ + {/* Upload */} +
+ + {uploading && } + {uploadSuccess && ( +

+ + Document uploaded successfully. +

+ )} + {uploadError && ( +

+ {uploadError} +

+ )} +
+ + {/* Document list */} +
+ + {loadingDocs ? ( + + ) : docsError ? ( +

+ {docsError} +

+ ) : docs.length === 0 ? ( +

+ No documents yet. +

+ ) : ( +
+ {docs.map((doc) => ( +
+ + {doc.filename} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/word-addin/src/taskpane/components/WorkflowPicker.tsx b/word-addin/src/taskpane/components/WorkflowPicker.tsx new file mode 100644 index 000000000..2d750b554 --- /dev/null +++ b/word-addin/src/taskpane/components/WorkflowPicker.tsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from "react"; +import { Workflow as WorkflowIcon, AlertCircle } from "lucide-react"; +import { listWorkflows } from "../api/mikeApi"; +import { streamAssistant } from "../api/stream"; +import type { Workflow } from "@mike/core"; +import { useWordDoc } from "../hooks/useWordDoc"; +import { Button } from "@mike/shared/ui/button"; +import { Label } from "@mike/shared/ui/label"; +import { Spinner } from "@mike/shared/ui/spinner"; +import { Select } from "@mike/shared/ui/select"; +import { Markdown } from "@mike/shared/chat/Markdown"; + +export function WorkflowPicker(): React.ReactElement { + const [workflows, setWorkflows] = useState([]); + const [fetchLoading, setFetchLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + const [selectedId, setSelectedId] = useState(""); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(""); + const [runError, setRunError] = useState(null); + const { readDocumentText, insertBelowSelection } = useWordDoc(); + + useEffect(() => { + listWorkflows("assistant") + .then((all) => { + // Server already scopes to type==="assistant"; keep the guard as a + // belt-and-braces filter and drop tabular/empty-prompt rows that aren't + // runnable as a document chat (they need column config + a different + // endpoint). + const data = (all ?? []).filter( + (w) => w.metadata.type === "assistant" && (w.skill_md ?? "").trim() + ); + setWorkflows(data); + if (data.length > 0) setSelectedId(data[0].id); + }) + .catch((e: unknown) => { + setFetchError( + e instanceof Error ? e.message : "Failed to load workflows" + ); + }) + .finally(() => setFetchLoading(false)); + }, []); + + const selectedWorkflow = workflows.find((w) => w.id === selectedId); + + const handleRun = async (): Promise => { + if (!selectedWorkflow) return; + setRunning(true); + setResult(""); + setRunError(null); + try { + const docText = await readDocumentText(); + let accumulated = ""; + // POST /chat does not read a `systemPrompt` field. The workflow + // instruction is sent as the user message and the document body is + // passed via `documentContext` (which the API folds into the system + // prompt as a spotlighted block). The model is injected by streamAssistant. + await streamAssistant( + { + messages: [ + { role: "user", content: selectedWorkflow.skill_md ?? "" }, + ], + documentContext: docText, + }, + (chunk) => { + accumulated += chunk; + setResult(accumulated); + } + ); + } catch (e) { + setRunError(e instanceof Error ? e.message : "Workflow run failed."); + } finally { + setRunning(false); + } + }; + + if (fetchLoading) { + return ( +
+ +
+ ); + } + + if (fetchError) { + return ( +
+ +

{fetchError}

+
+ ); + } + + if (workflows.length === 0) { + return ( +
+
+ +
+

No workflows found.

+

+ Create an assistant workflow in the Mike web app and it will appear + here. +

+
+ ); + } + + return ( +
+
+ + + {selectedWorkflow?.metadata.practice && ( +

+ {selectedWorkflow.metadata.practice} +

+ )} +
+ + + + {running && } + + {runError && ( +

+ {runError} +

+ )} + + {result && ( +
+
+ {result} +
+ {!running && ( + + )} +
+ )} +
+ ); +} diff --git a/word-addin/src/taskpane/hooks/useWordDoc.ts b/word-addin/src/taskpane/hooks/useWordDoc.ts new file mode 100644 index 000000000..6ab2039d7 --- /dev/null +++ b/word-addin/src/taskpane/hooks/useWordDoc.ts @@ -0,0 +1,214 @@ +/// + +import { toWordParagraphs, toWordText } from "../lib/wordText"; + +export interface WordSelectionAnchor { + range: Word.Range; + originalText: string; +} + +/** + * Hook exposing document read/write helpers that wrap the Word JS API. + * All functions return Promises and must be called in a component context + * where Office.js has already initialised (i.e. inside Office.onReady). + */ +export function useWordDoc() { + /** Read the plain text of the entire document body. */ + const readDocumentText = (): Promise => + Word.run(async (context) => { + const body = context.document.body; + body.load("text"); + await context.sync(); + return body.text; + }); + + /** Read the full OOXML of the document body. */ + const readDocumentOoxml = (): Promise => + Word.run(async (context) => { + const body = context.document.body; + const ooxml = body.getOoxml(); + await context.sync(); + return ooxml.value; + }); + + /** + * Return the current document as a real binary .docx Blob by reading it + * via the Office compressed-file API. The file is streamed in 64 KB slices + * which are reassembled in order before being wrapped in a Blob. + */ + const getDocxBlob = (): Promise => + new Promise((resolve, reject) => { + Office.context.document.getFileAsync( + Office.FileType.Compressed, + { sliceSize: 65536 }, + (result) => { + if (result.status === Office.AsyncResultStatus.Failed) { + reject(new Error(result.error.message)); + return; + } + const file = result.value; + const sliceCount = file.sliceCount; + + // A blank / never-saved document can report zero slices. The loop + // below would then never run, no getSliceAsync callback would fire, + // and this Promise would hang forever (button stuck on "Uploading…", + // file handle leaked). Fail fast instead. + if (sliceCount === 0) { + file.closeAsync(); + reject(new Error("The document appears to be empty.")); + return; + } + + const slices: Uint8Array[] = []; + let received = 0; + + for (let i = 0; i < sliceCount; i++) { + file.getSliceAsync(i, (sliceResult) => { + if (sliceResult.status === Office.AsyncResultStatus.Failed) { + file.closeAsync(); + reject(new Error(sliceResult.error.message)); + return; + } + slices[sliceResult.value.index] = new Uint8Array( + sliceResult.value.data + ); + received++; + if (received === sliceCount) { + file.closeAsync(); + const total = slices.reduce((acc, s) => acc + s.length, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const s of slices) { + merged.set(s, offset); + offset += s.length; + } + resolve( + new Blob([merged], { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }) + ); + } + }); + } + } + ); + }); + + /** + * Capture the exact selection the user asked Mike to rewrite. Tracking the + * range lets Word adjust its position if unrelated text changes while the + * model is responding, without falling back to an ambiguous body search. + */ + const captureSelection = (): Promise => + Word.run(async (context) => { + const range = context.document.getSelection(); + range.load("text"); + range.track(); + await context.sync(); + return { range, originalText: range.text }; + }); + + const releaseSelection = (anchor: WordSelectionAnchor): Promise => + Word.run(anchor.range, async (context) => { + anchor.range.untrack(); + await context.sync(); + }); + + /** + * Replace the exact range captured for the rewrite. Refuse to apply if the + * user edited that range while Mike was responding. + */ + const replaceSelection = ( + anchor: WordSelectionAnchor, + newText: string, + tracked: boolean + ): Promise<"applied" | "stale"> => + Word.run(anchor.range, async (context) => { + const doc = context.document; + anchor.range.load("text"); + doc.load("changeTrackingMode"); + await context.sync(); + + if (anchor.range.text !== anchor.originalText) return "stale"; + + const originalMode = doc.changeTrackingMode; + + try { + if (tracked) doc.changeTrackingMode = Word.ChangeTrackingMode.trackAll; + anchor.range.insertText(toWordText(newText), Word.InsertLocation.replace); + await context.sync(); + return "applied"; + } finally { + if (tracked) { + doc.changeTrackingMode = originalMode; + await context.sync(); + } + } + }); + + /** + * Insert generated content below the paragraph containing the current + * selection. This never overwrites selected text. Each model paragraph is a + * real Word paragraph and inherits the surrounding paragraph style and + * direct spacing/indentation, instead of inserting raw Markdown into one run. + */ + const insertBelowSelection = (text: string, tracked = false): Promise => + Word.run(async (context) => { + const doc = context.document; + const source = doc.getSelection().paragraphs.getLast(); + source.load([ + "style", + "alignment", + "firstLineIndent", + "leftIndent", + "lineSpacing", + "rightIndent", + "spaceAfter", + "spaceBefore", + ]); + doc.load("changeTrackingMode"); + await context.sync(); + + const paragraphs = toWordParagraphs(text); + if (paragraphs.length === 0) throw new Error("There is no text to insert."); + + const originalMode = doc.changeTrackingMode; + + try { + if (tracked) doc.changeTrackingMode = Word.ChangeTrackingMode.trackAll; + + let previous = source; + for (const paragraphText of paragraphs) { + const inserted = previous.insertParagraph( + paragraphText, + Word.InsertLocation.after + ); + inserted.style = source.style; + inserted.alignment = source.alignment; + inserted.firstLineIndent = source.firstLineIndent; + inserted.leftIndent = source.leftIndent; + inserted.lineSpacing = source.lineSpacing; + inserted.rightIndent = source.rightIndent; + inserted.spaceAfter = source.spaceAfter; + inserted.spaceBefore = source.spaceBefore; + previous = inserted; + } + await context.sync(); + } finally { + if (tracked) { + doc.changeTrackingMode = originalMode; + await context.sync(); + } + } + }); + + return { + readDocumentText, + readDocumentOoxml, + getDocxBlob, + captureSelection, + releaseSelection, + replaceSelection, + insertBelowSelection, + }; +} diff --git a/word-addin/src/taskpane/index.html b/word-addin/src/taskpane/index.html new file mode 100644 index 000000000..13a1dc3e4 --- /dev/null +++ b/word-addin/src/taskpane/index.html @@ -0,0 +1,37 @@ + + + + + + + Mike Legal AI + + + + + + + + + + +
+ + + diff --git a/word-addin/src/taskpane/index.tsx b/word-addin/src/taskpane/index.tsx new file mode 100644 index 000000000..13919c0ee --- /dev/null +++ b/word-addin/src/taskpane/index.tsx @@ -0,0 +1,21 @@ +/// +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +Office.onReady(() => { + const container = document.getElementById("root"); + if (!container) { + throw new Error("Root element #root not found in DOM"); + } + const root = createRoot(container); + root.render( + // `@container` makes the whole pane a query container so descendants can + // adapt spacing/type to the (resizable, usually narrow) task-pane width + // via `@sm:`/`@md:` variants — viewport breakpoints never fire in a pane. +
+ +
+ ); +}); diff --git a/word-addin/src/taskpane/lib/wordText.ts b/word-addin/src/taskpane/lib/wordText.ts new file mode 100644 index 000000000..0dfff3b7f --- /dev/null +++ b/word-addin/src/taskpane/lib/wordText.ts @@ -0,0 +1,59 @@ +/** + * Convert model-authored Markdown-ish text into document text that Word can + * place without leaking chat formatting tokens into a legal document. + * + * This is intentionally conservative: it preserves wording, numbering and + * paragraph boundaries, but removes presentation-only Markdown. Word then + * applies the surrounding document's paragraph formatting during insertion. + */ +export function toWordParagraphs(value: string): string[] { + let text = value.replace(/\r\n?/g, "\n").trim(); + + const fenced = text.match(/^```[^\n]*\n([\s\S]*?)\n```$/); + if (fenced) text = fenced[1].trim(); + + const paragraphs: string[] = []; + let previousWasBlank = false; + + for (const sourceLine of text.split("\n")) { + const trimmed = sourceLine.trim(); + if (!trimmed) { + if (paragraphs.length > 0 && !previousWasBlank) paragraphs.push(""); + previousWasBlank = true; + continue; + } + + // Markdown table divider rows are presentation syntax, not content. + if (/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(trimmed)) { + continue; + } + + let line = trimmed + .replace(/^#{1,6}\s+/, "") + .replace(/^>\s?/, "") + .replace(/^[-*+]\s+/, "• ") + .replace(/^\|(.+)\|$/, (_match, cells: string) => + cells + .split("|") + .map((cell) => cell.trim()) + .join("\t") + ) + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/(\*\*|__)(.*?)\1/g, "$2") + .replace(/(\*|_)(.*?)\1/g, "$2") + .replace(/~~(.*?)~~/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .trim(); + + if (line) paragraphs.push(line); + previousWasBlank = false; + } + + while (paragraphs.at(-1) === "") paragraphs.pop(); + return paragraphs; +} + +export function toWordText(value: string): string { + return toWordParagraphs(value).join("\n"); +} diff --git a/word-addin/src/taskpane/styles.css b/word-addin/src/taskpane/styles.css new file mode 100644 index 000000000..4c87a7776 --- /dev/null +++ b/word-addin/src/taskpane/styles.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +/* Shared design tokens (theme mappings, color variables, base layer) — vendored + from the fork's packages/shared, the single source of truth shared with its + web app. */ +@import "../vendor/shared/styles/tokens.css"; + +/* Tailwind v4 content detection: scan the add-in source (which includes the + vendored shared component sources) so their utility classes are emitted. */ +@source "../"; + +/* The web supplies these font variables via next/font; the add-in supplies + them here (the actual font files are loaded via a in index.html). + Falls back to the system stack when offline. */ +:root { + --font-inter: "Inter", ui-sans-serif, system-ui, -apple-system, + "Segoe UI", Roboto, sans-serif; + --font-eb-garamond: "EB Garamond", Georgia, "Times New Roman", serif; +} + +/* The task pane is a fixed-height webview; let the app own scrolling. */ +html, +body, +#root { + height: 100%; +} diff --git a/word-addin/src/vendor/api-client/index.ts b/word-addin/src/vendor/api-client/index.ts new file mode 100644 index 000000000..5022e6bc7 --- /dev/null +++ b/word-addin/src/vendor/api-client/index.ts @@ -0,0 +1,1708 @@ +import type { + AssistantEvent, + ApiKeyProvider, + ApiKeySource, + Chat, + ChatDetailOut, + Citation, + Document, + Folder, + LibraryFolder, + Message, + OpenSourceWorkflowContributorMode, + OpenSourceWorkflowResponse, + Project, + Workflow, + WorkflowContributor, + TabularReview, + TabularReviewDetailOut, +} from "@mike/core"; + +export type { ApiKeyProvider, ApiKeySource } from "@mike/core"; + +// MERGE-REVIEW: the fork's createMikeApiClient helper (below) references +// Mike-prefixed type names; @mike/core exports the unprefixed types the rest of +// this client already uses, so alias them here to keep one source of truth. +type MikeProject = Project; +type MikeChat = Chat; + +export type AuthHeaderProvider = () => Promise>; + +export type MikeApiClientConfig = { + baseUrl?: string; + getAuthHeaders?: AuthHeaderProvider; + fetchImpl?: typeof fetch; +}; + +type ResolvedMikeApiClientConfig = Required; + +// Server-side shape before mapping +interface ServerMessage { + id: string; + chat_id: string; + role: "user" | "assistant"; + content: string | AssistantEvent[] | null; + files?: { filename: string; document_id?: string }[] | null; + workflow?: { id: string; title: string } | null; + citations?: Citation[] | null; + created_at: string; +} +interface ServerChatDetailOut { + chat: Chat; + messages: ServerMessage[]; +} + +declare const process: { env?: Record } | undefined; + +const API_BASE = + process?.env?.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; +const isDev = process?.env?.NODE_ENV !== "production"; +const devLog = (...args: Parameters) => { + if (isDev) console.log(...args); +}; + +export class MikeApiError extends Error { + status: number; + code: string | null; + + constructor(args: { message: string; status: number; code?: string | null }) { + super(args.message); + this.name = "MikeApiError"; + this.status = args.status; + this.code = args.code ?? null; + } +} + +export function isMfaRequiredError(error: unknown) { + return ( + error instanceof MikeApiError && + error.status === 403 && + error.code === "mfa_verification_required" + ); +} + +const DEFAULT_API_BASE = + process?.env?.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + +let clientConfig: ResolvedMikeApiClientConfig = { + baseUrl: DEFAULT_API_BASE, + getAuthHeaders: async () => ({}), + // Arrow wrapper so fetch is always invoked as a plain function, never as a + // method of clientConfig — calling a detached native `fetch` as a method + // throws "Illegal invocation" in Chromium (caught by the Playwright suite). + fetchImpl: (...args: Parameters) => fetch(...args), +}; + +function resolveMikeApiClientConfig( + config: MikeApiClientConfig = {}, + base: ResolvedMikeApiClientConfig = clientConfig, +): ResolvedMikeApiClientConfig { + return { + ...base, + ...config, + baseUrl: config.baseUrl ?? base.baseUrl, + getAuthHeaders: config.getAuthHeaders ?? base.getAuthHeaders, + fetchImpl: config.fetchImpl ?? base.fetchImpl, + }; +} + +export function configureMikeApiClient(config: MikeApiClientConfig): void { + clientConfig = resolveMikeApiClientConfig(config); +} + +async function getAuthHeader( + config: ResolvedMikeApiClientConfig = clientConfig, +): Promise> { + return config.getAuthHeaders(); +} + +function apiUrl( + path: string, + config: ResolvedMikeApiClientConfig = clientConfig, +): string { + return `${config.baseUrl}${path}`; +} + +async function apiRequestWithConfig( + config: ResolvedMikeApiClientConfig, + path: string, + init?: RequestInit, +): Promise { + const authHeaders = await getAuthHeader(config); + const { headers: initHeaders, ...restInit } = init ?? {}; + const response = await config.fetchImpl(apiUrl(path, config), { + cache: "no-store", + ...restInit, + headers: { + Accept: "application/json", + ...authHeaders, + ...(initHeaders as Record | undefined), + }, + }); + + if (!response.ok) { + throw await toApiError(response, path); + } + + if ( + response.status === 204 || + response.headers.get("content-length") === "0" + ) { + return undefined as T; + } + + return (await response.json()) as T; +} + +async function apiRequest(path: string, init?: RequestInit): Promise { + return apiRequestWithConfig(clientConfig, path, init); +} + +async function apiBlobRequest(path: string): Promise<{ + blob: Blob; + filename: string | null; +}> { + const authHeaders = await getAuthHeader(); + const response = await clientConfig.fetchImpl(apiUrl(path), { + cache: "no-store", + headers: { + Accept: "application/json", + ...authHeaders, + }, + }); + + if (!response.ok) { + throw await toApiError(response, path); + } + + const disposition = response.headers.get("content-disposition") ?? ""; + const filenameMatch = disposition.match(/filename="?([^";]+)"?/i); + return { + blob: await response.blob(), + filename: filenameMatch?.[1] ?? null, + }; +} + +async function toApiError(response: Response, path: string) { + const text = await response.text(); + try { + const parsed = JSON.parse(text) as { + detail?: unknown; + code?: unknown; + error?: { code?: unknown; message?: unknown }; + }; + // MERGE-REVIEW: the fork backend returns both `{ error: { code, message } }` + // and `{ detail, code }` error shapes; handle either so MikeApiError carries + // an accurate code/message in all cases. + const code = + typeof parsed.error?.code === "string" + ? parsed.error.code + : typeof parsed.code === "string" + ? parsed.code + : null; + const message = + typeof parsed.error?.message === "string" && parsed.error.message + ? parsed.error.message + : typeof parsed.detail === "string" && parsed.detail + ? parsed.detail + : `API error: ${response.status}`; + devLog("[mike-api] non-ok response", { + path, + status: response.status, + code, + detail: parsed.detail, + }); + return new MikeApiError({ + status: response.status, + code, + message, + }); + } catch { + devLog("[mike-api] non-ok non-json response", { + path, + status: response.status, + bodyPreview: text.slice(0, 200), + }); + return new MikeApiError({ + status: response.status, + message: text || `API error: ${response.status}`, + }); + } +} + +// --------------------------------------------------------------------------- +// Projects +// --------------------------------------------------------------------------- + +export async function listProjects(): Promise { + return apiRequest("/projects"); +} + +export async function createProject( + name: string, + cm_number?: string, + practice?: string, + shared_with?: string[], +): Promise { + return apiRequest("/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, cm_number, practice, shared_with }), + }); +} + +export async function deleteAccount(): Promise { + return apiRequest("/user/account", { method: "DELETE" }); +} + +export async function deleteAllChats(): Promise { + return apiRequest("/user/chats", { method: "DELETE" }); +} + +export async function deleteAllProjects(): Promise { + return apiRequest("/user/projects", { method: "DELETE" }); +} + +export async function deleteAllTabularReviews(): Promise { + return apiRequest("/user/tabular-reviews", { method: "DELETE" }); +} + +export async function exportAccountData(): Promise<{ + blob: Blob; + filename: string | null; +}> { + return apiBlobRequest("/user/export"); +} + +export async function exportChatData(): Promise<{ + blob: Blob; + filename: string | null; +}> { + return apiBlobRequest("/user/chats/export"); +} + +export async function exportTabularReviewsData(): Promise<{ + blob: Blob; + filename: string | null; +}> { + return apiBlobRequest("/user/tabular-reviews/export"); +} + +export interface UserProfile { + displayName: string | null; + organisation: string | null; + messageCreditsUsed: number; + creditsResetDate: string; + creditsRemaining: number; + tier: string; + titleModel: string; + tabularModel: string; + mfaOnLogin: boolean; + legalResearchUs: boolean; + apiKeyStatus: ApiKeyStatus; +} + +export interface UserLookupResult { + exists: boolean; + email: string; + display_name: string | null; +} + +export async function getUserProfile(): Promise { + return apiRequest("/user/profile"); +} + +export async function lookupUserByEmail( + email: string, +): Promise { + return apiRequest( + `/user/lookup?email=${encodeURIComponent(email)}`, + ); +} + +export async function updateUserProfile(payload: { + displayName?: string | null; + organisation?: string | null; + titleModel?: string; + tabularModel?: string; + legalResearchUs?: boolean; +}): Promise { + return apiRequest("/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function updateUserMfaOnLogin( + enabled: boolean, +): Promise { + return apiRequest("/user/security/mfa-login", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); +} + +// MERGE-REVIEW: upstream defined ApiKeyProvider/ApiKeySource locally with the +// extra "openrouter" and "courtlistener" providers. The fork sources these +// types from @mike/core (imported above), so the local redefinition is dropped +// to keep a single source of truth — @mike/core's ApiKeyProvider should be +// extended with "openrouter" and "courtlistener" to match the backend schema. +export type ApiKeyState = Record< + ApiKeyProvider, + { + configured: boolean; + source: ApiKeySource; + } +>; + +export type ApiKeyStatus = Record & { + sources?: Partial>; +}; + +export async function getApiKeyStatus(): Promise { + return apiRequest("/user/api-keys"); +} + +export async function saveApiKey( + provider: ApiKeyProvider, + apiKey: string | null, +): Promise { + return apiRequest(`/user/api-keys/${provider}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ api_key: apiKey }), + }); +} + +export interface McpToolSummary { + id: string; + toolName: string; + openaiToolName: string; + title: string | null; + description: string | null; + enabled: boolean; + readOnly: boolean; + destructive: boolean; + requiresConfirmation: boolean; + lastSeenAt: string; +} + +export interface McpConnectorSummary { + id: string; + name: string; + transport: "streamable_http"; + serverUrl: string; + authType: "none" | "bearer" | "oauth"; + enabled: boolean; + hasAuthConfig: boolean; + customHeaderKeys: string[]; + oauthConnected: boolean; + toolPolicy: Record; + tools: McpToolSummary[]; + toolCount: number; + createdAt: string; + updatedAt: string; +} + +export async function listMcpConnectors(): Promise { + return apiRequest("/user/mcp-connectors"); +} + +export async function getMcpConnector( + connectorId: string, +): Promise { + return apiRequest( + `/user/mcp-connectors/${connectorId}`, + ); +} + +export async function createMcpConnector(payload: { + name: string; + serverUrl: string; + bearerToken?: string | null; + headers?: Record; +}): Promise { + return apiRequest("/user/mcp-connectors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function updateMcpConnector( + connectorId: string, + payload: { + name?: string; + serverUrl?: string; + enabled?: boolean; + bearerToken?: string | null; + headers?: Record; + }, +): Promise { + return apiRequest( + `/user/mcp-connectors/${connectorId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); +} + +export async function deleteMcpConnector(connectorId: string): Promise { + return apiRequest(`/user/mcp-connectors/${connectorId}`, { + method: "DELETE", + }); +} + +export async function refreshMcpConnectorTools( + connectorId: string, +): Promise { + return apiRequest( + `/user/mcp-connectors/${connectorId}/refresh-tools`, + { method: "POST" }, + ); +} + +export async function startMcpConnectorOAuth( + connectorId: string, +): Promise<{ authorizationUrl: string | null; alreadyAuthorized: boolean }> { + return apiRequest<{ authorizationUrl: string | null; alreadyAuthorized: boolean }>( + `/user/mcp-connectors/${connectorId}/oauth/start`, + { method: "POST" }, + ); +} + +export async function setMcpToolEnabled( + connectorId: string, + toolId: string, + enabled: boolean, +): Promise { + return apiRequest( + `/user/mcp-connectors/${connectorId}/tools/${toolId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ); +} + +export async function getProject(projectId: string): Promise { + return apiRequest(`/projects/${projectId}`); +} + +export async function updateProject( + projectId: string, + payload: { + name?: string; + cm_number?: string; + practice?: string | null; + shared_with?: string[]; + }, +): Promise { + return apiRequest(`/projects/${projectId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function deleteProject(projectId: string): Promise { + await apiRequest(`/projects/${projectId}`, { method: "DELETE" }); +} + +export interface ProjectPeople { + owner: { + user_id: string; + email: string | null; + display_name: string | null; + }; + members: { email: string; display_name: string | null }[]; +} + +export async function getProjectPeople( + projectId: string, +): Promise { + return apiRequest(`/projects/${projectId}/people`); +} + +// --------------------------------------------------------------------------- +// Documents +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Folders +// --------------------------------------------------------------------------- + +export async function createProjectFolder( + projectId: string, + name: string, + parentFolderId?: string | null, +): Promise { + return apiRequest(`/projects/${projectId}/folders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + parent_folder_id: parentFolderId ?? null, + }), + }); +} + +export async function renameProjectFolder( + projectId: string, + folderId: string, + name: string, +): Promise { + return apiRequest( + `/projects/${projectId}/folders/${folderId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }, + ); +} + +export async function deleteProjectFolder( + projectId: string, + folderId: string, +): Promise { + await apiRequest(`/projects/${projectId}/folders/${folderId}`, { + method: "DELETE", + }); +} + +export async function moveSubfolderToFolder( + projectId: string, + folderId: string, + parentFolderId: string | null, +): Promise { + return apiRequest( + `/projects/${projectId}/folders/${folderId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parent_folder_id: parentFolderId }), + }, + ); +} + +export async function moveDocumentToFolder( + projectId: string, + documentId: string, + folderId: string | null, +): Promise { + return apiRequest( + `/projects/${projectId}/documents/${documentId}/folder`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder_id: folderId }), + }, + ); +} + +export async function renameProjectDocument( + projectId: string, + documentId: string, + filename: string, +): Promise { + return apiRequest( + `/projects/${projectId}/documents/${documentId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }, + ); +} + +export async function addDocumentToProject( + projectId: string, + documentId: string, +): Promise { + return apiRequest( + `/projects/${projectId}/documents/${documentId}`, + { method: "POST" }, + ); +} + +export interface DocumentVersion { + id: string; + version_number: number | null; + source: string; + created_at: string; + filename: string | null; + file_type?: string | null; + size_bytes?: number | null; + page_count?: number | null; + deleted_at?: string | null; + deleted_by?: string | null; +} + +export async function listDocumentVersions(documentId: string): Promise<{ + current_version_id: string | null; + versions: DocumentVersion[]; +}> { + return apiRequest(`/single-documents/${documentId}/versions`); +} + +export async function uploadDocumentVersion( + documentId: string, + file: File, + filename?: string, +): Promise { + const authHeaders = await getAuthHeader(); + const form = new FormData(); + form.append("file", file); + if (filename) form.append("filename", filename); + const response = await clientConfig.fetchImpl( + apiUrl(`/single-documents/${documentId}/versions`), + { + method: "POST", + headers: { ...authHeaders }, + body: form, + }, + ); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise; +} + +export async function replaceDocumentVersionFile( + documentId: string, + versionId: string, + file: File, + filename?: string, +): Promise { + const authHeaders = await getAuthHeader(); + const form = new FormData(); + form.append("file", file); + if (filename) form.append("filename", filename); + const response = await fetch( + `${API_BASE}/single-documents/${documentId}/versions/${versionId}/file`, + { + method: "PUT", + headers: { ...authHeaders }, + body: form, + }, + ); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise; +} + +export async function copyDocumentVersionFromDocument( + documentId: string, + sourceDocumentId: string, + filename?: string, +): Promise { + return apiRequest( + `/single-documents/${documentId}/versions/from-document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source_document_id: sourceDocumentId, + filename, + }), + }, + ); +} + +export async function renameDocumentVersion( + documentId: string, + versionId: string, + filename: string | null, +): Promise { + return apiRequest( + `/single-documents/${documentId}/versions/${versionId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }, + ); +} + +export async function deleteDocumentVersion( + documentId: string, + versionId: string, +): Promise<{ + deleted_version_id: string; + current_version_id: string | null; +}> { + return apiRequest(`/single-documents/${documentId}/versions/${versionId}`, { + method: "DELETE", + }); +} + +export async function uploadProjectDocument( + projectId: string, + file: File, +): Promise { + return uploadProjectDocumentWithConfig(clientConfig, projectId, file); +} + +async function uploadProjectDocumentWithConfig( + config: ResolvedMikeApiClientConfig, + projectId: string, + file: File, +): Promise { + const authHeaders = await getAuthHeader(config); + const form = new FormData(); + form.append("file", file); + const response = await config.fetchImpl( + apiUrl(`/projects/${projectId}/documents`, config), + { + method: "POST", + headers: { ...authHeaders }, + body: form, + }, + ); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise; +} + +export async function uploadStandaloneDocument( + file: File, +): Promise { + return uploadStandaloneDocumentWithConfig(clientConfig, file); +} + +async function uploadStandaloneDocumentWithConfig( + config: ResolvedMikeApiClientConfig, + file: File, +): Promise { + const authHeaders = await getAuthHeader(config); + const form = new FormData(); + form.append("file", file); + const response = await config.fetchImpl( + apiUrl(`/single-documents`, config), + { + method: "POST", + headers: { ...authHeaders }, + body: form, + }, + ); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise; +} + +export async function listStandaloneDocuments(): Promise { + return apiRequest("/single-documents"); +} + +export async function deleteDocument(documentId: string): Promise { + await apiRequest(`/single-documents/${documentId}`, { method: "DELETE" }); +} + +export async function getDocumentUrl( + documentId: string, + versionId?: string | null, +): Promise<{ url: string; filename: string; version_id: string | null }> { + const qs = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; + return apiRequest(`/single-documents/${documentId}/url${qs}`); +} + +export async function downloadDocumentsZip( + documentIds: string[], +): Promise { + const authHeaders = await getAuthHeader(); + const response = await clientConfig.fetchImpl( + apiUrl(`/single-documents/download-zip`), + { + method: "POST", + cache: "no-store", + headers: { + "Content-Type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ document_ids: documentIds }), + }, + ); + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `API error: ${response.status}`); + } + return response.blob(); +} + +// --------------------------------------------------------------------------- +// Chat +// --------------------------------------------------------------------------- + +export async function createChat(payload?: { + project_id?: string; +}): Promise<{ id: string }> { + return apiRequest<{ id: string }>("/chat/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload ?? {}), + }); +} + +export async function listChats(options?: { + limit?: number; +}): Promise { + const params = new URLSearchParams(); + if (options?.limit) params.set("limit", String(options.limit)); + const query = params.toString(); + return apiRequest(`/chat${query ? `?${query}` : ""}`); +} + +export async function listProjectChats(projectId: string): Promise { + return apiRequest(`/projects/${projectId}/chats`); +} + +export async function getChat(chatId: string): Promise { + return getChatWithConfig(clientConfig, chatId); +} + +async function getChatWithConfig( + config: ResolvedMikeApiClientConfig, + chatId: string, +): Promise { + const raw = await apiRequestWithConfig( + config, + `/chat/${chatId}`, + ); + const messages: Message[] = raw.messages.map((m) => { + if (m.role === "user") { + return { + id: m.id, + role: "user", + content: typeof m.content === "string" ? m.content : "", + files: m.files ?? undefined, + workflow: m.workflow ?? undefined, + }; + } + const events = Array.isArray(m.content) + ? (m.content as AssistantEvent[]) + : undefined; + return { + id: m.id, + role: "assistant", + content: + events + ?.filter((e) => e.type === "content") + .map((e) => (e as { type: "content"; text: string }).text) + .join("") ?? "", + citations: m.citations ?? undefined, + events, + }; + }); + return { chat: raw.chat, messages }; +} + +export function createMikeApiClient(config: MikeApiClientConfig = {}) { + const scopedConfig = resolveMikeApiClientConfig(config, { + baseUrl: DEFAULT_API_BASE, + getAuthHeaders: async () => ({}), + // See note above: arrow wrapper avoids "Illegal invocation" in Chromium. + fetchImpl: (...args: Parameters) => fetch(...args), + }); + + return { + projects: { + list: () => + apiRequestWithConfig(scopedConfig, "/projects"), + create: ( + name: string, + cm_number?: string, + shared_with?: string[], + ) => + apiRequestWithConfig(scopedConfig, "/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, cm_number, shared_with }), + }), + get: (projectId: string) => + apiRequestWithConfig( + scopedConfig, + `/projects/${projectId}`, + ), + update: ( + projectId: string, + payload: { + name?: string; + cm_number?: string; + shared_with?: string[]; + }, + ) => + apiRequestWithConfig( + scopedConfig, + `/projects/${projectId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ), + delete: (projectId: string) => + apiRequestWithConfig( + scopedConfig, + `/projects/${projectId}`, + { + method: "DELETE", + }, + ), + }, + chats: { + create: (payload?: { project_id?: string }) => + apiRequestWithConfig<{ id: string }>( + scopedConfig, + "/chat/create", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload ?? {}), + }, + ), + list: (options?: { limit?: number }) => { + const params = new URLSearchParams(); + if (options?.limit) params.set("limit", String(options.limit)); + const query = params.toString(); + return apiRequestWithConfig( + scopedConfig, + `/chat${query ? `?${query}` : ""}`, + ); + }, + get: (chatId: string) => getChatWithConfig(scopedConfig, chatId), + }, + documents: { + uploadToProject: (projectId: string, file: File) => + uploadProjectDocumentWithConfig(scopedConfig, projectId, file), + uploadStandalone: (file: File) => + uploadStandaloneDocumentWithConfig(scopedConfig, file), + }, + }; +} + +export async function renameChat(chatId: string, title: string): Promise { + await apiRequest(`/chat/${chatId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); +} + +export async function deleteChat(chatId: string): Promise { + await apiRequest(`/chat/${chatId}`, { method: "DELETE" }); +} + +// --------------------------------------------------------------------------- +// Library (files + templates) +// --------------------------------------------------------------------------- + +export type LibraryKind = "files" | "templates"; + +export interface LibraryCollection { + documents: Document[]; + folders: LibraryFolder[]; +} + +export async function getLibrary( + kind: LibraryKind, +): Promise { + return apiRequest(`/library/${kind}`); +} + +export async function uploadLibraryDocument( + kind: LibraryKind, + file: File, +): Promise { + return uploadLibraryDocumentWithConfig(clientConfig, kind, file); +} + +async function uploadLibraryDocumentWithConfig( + config: ResolvedMikeApiClientConfig, + kind: LibraryKind, + file: File, +): Promise { + const authHeaders = await getAuthHeader(config); + const form = new FormData(); + form.append("file", file); + const response = await config.fetchImpl( + apiUrl(`/library/${kind}/documents`, config), + { + method: "POST", + headers: { ...authHeaders }, + body: form, + }, + ); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise; +} + +export async function createLibraryFolder( + kind: LibraryKind, + name: string, + parentFolderId?: string | null, +): Promise { + return apiRequest(`/library/${kind}/folders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + parent_folder_id: parentFolderId ?? null, + }), + }); +} + +export async function renameLibraryFolder( + kind: LibraryKind, + folderId: string, + name: string, +): Promise { + return apiRequest(`/library/${kind}/folders/${folderId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); +} + +export async function deleteLibraryFolder( + kind: LibraryKind, + folderId: string, +): Promise { + await apiRequest(`/library/${kind}/folders/${folderId}`, { + method: "DELETE", + }); +} + +export async function moveLibraryFolder( + kind: LibraryKind, + folderId: string, + parentFolderId: string | null, +): Promise { + return apiRequest(`/library/${kind}/folders/${folderId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parent_folder_id: parentFolderId }), + }); +} + +export async function moveLibraryDocument( + kind: LibraryKind, + documentId: string, + folderId: string | null, +): Promise { + return apiRequest( + `/library/${kind}/documents/${documentId}/folder`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder_id: folderId }), + }, + ); +} + +export async function renameLibraryDocument( + kind: LibraryKind, + documentId: string, + filename: string, +): Promise { + return apiRequest(`/library/${kind}/documents/${documentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }); +} + +export async function renameTabularChat( + reviewId: string, + chatId: string, + title: string, +): Promise { + await apiRequest(`/tabular-review/${reviewId}/chats/${chatId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); +} + +export async function generateChatTitle( + chatId: string, + message: string, +): Promise<{ title: string }> { + return apiRequest<{ title: string }>(`/chat/${chatId}/generate-title`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }); +} + +export type CaseLawOpinion = { + opinionId: number | null; + apiUrl?: string | null; + type: string | null; + author: string | null; + url: string | null; + text?: string | null; + html?: string | null; +}; + +export async function getCourtlistenerOpinions( + clusterId: number, +): Promise { + const result = await apiRequest<{ opinions: CaseLawOpinion[] }>( + "/case-law/case-opinions", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clusterId, + }), + }, + ); + return result.opinions; +} + +export async function streamChat(payload: { + messages: { + role: string; + content: string; + files?: { filename: string; document_id?: string }[]; + workflow?: { id: string; title: string }; + }[]; + chat_id?: string; + project_id?: string; + model?: string; + documentContext?: string; + ask_inputs_response?: { + responses: ( + | { + id: string; + kind: "choice"; + question: string; + answer?: string; + skipped?: boolean; + } + | { + id: string; + kind: "documents"; + filenames: string[]; + skipped?: boolean; + } + )[]; + }; + signal?: AbortSignal; +}): Promise { + const { signal, ...body } = payload; + const authHeaders = await getAuthHeader(); + return clientConfig.fetchImpl(apiUrl(`/chat`), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + ...authHeaders, + }, + body: JSON.stringify(body), + signal, + }); +} + +/** + * Read an SSE (text/event-stream) response body frame-by-frame and hand each + * parsed `data:` payload to `onEvent`. Pure transport: it does not interpret + * event types — that is the caller's job. The Word add-in uses this shared + * transport instead of carrying a second, subtly different SSE parser. + * + * `[DONE]` is terminal: the backend emits a harmless trailing `{"type":"error"}` + * frame after it, and callers rely on never seeing anything past `[DONE]`. + */ +export async function readSSE( + response: Response, + onEvent: (data: unknown) => void, + options?: { signal?: AbortSignal }, +): Promise<{ done: boolean }> { + if (!response.body) { + throw new Error("Response body is null — streaming not supported"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let cancelled = false; + const cancel = async () => { + if (cancelled) return; + cancelled = true; + await reader.cancel().catch(() => {}); + }; + + // Abort the read if the caller's signal fires mid-stream. + const signal = options?.signal; + const onAbort = () => { + void cancel(); + }; + signal?.addEventListener("abort", onAbort); + + // Returns true when the line was the terminal [DONE] frame. + const processLine = (line: string): boolean => { + const trimmed = line.trim(); + if (!trimmed) return false; + if (!trimmed.startsWith("data:")) return false; + const dataStr = trimmed.slice(5).trim(); + if (dataStr === "[DONE]") return true; + try { + const parsed = JSON.parse(dataStr); + onEvent(parsed); + } catch { + // Malformed control noise — swallow silently. + } + return false; + }; + + try { + if (signal?.aborted) return { done: false }; + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + // Flush any trailing partial line; report whether it was [DONE]. + return { done: processLine(buffer) }; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (processLine(line)) return { done: true }; + } + } + } finally { + signal?.removeEventListener("abort", onAbort); + await cancel(); + } +} + +type StreamChatMessage = { + role: string; + content: string; + files?: { filename: string; document_id?: string }[]; + workflow?: { id: string; title: string }; +}; + +export async function streamProjectChat(payload: { + projectId: string; + messages: StreamChatMessage[]; + chat_id?: string; + model?: string; + displayed_doc?: { filename: string; document_id: string }; + attached_documents?: { filename: string; document_id: string }[]; + ask_inputs_response?: { + responses: ( + | { + id: string; + kind: "choice"; + question: string; + answer?: string; + skipped?: boolean; + } + | { + id: string; + kind: "documents"; + filenames: string[]; + skipped?: boolean; + } + )[]; + }; + signal?: AbortSignal; +}): Promise { + const { projectId, signal, ...body } = payload; + const authHeaders = await getAuthHeader(); + return clientConfig.fetchImpl(apiUrl(`/projects/${projectId}/chat`), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + ...authHeaders, + }, + body: JSON.stringify(body), + signal, + }); +} + +// --------------------------------------------------------------------------- +// Tabular Review +// --------------------------------------------------------------------------- + +export async function listTabularReviews( + projectId?: string, +): Promise { + const qs = projectId ? `?project_id=${encodeURIComponent(projectId)}` : ""; + return apiRequest(`/tabular-review${qs}`); +} + +export async function createTabularReview(payload: { + title?: string; + document_ids: string[]; + columns_config: { index: number; name: string; prompt: string }[]; + workflow_id?: string; + project_id?: string; +}): Promise { + return apiRequest("/tabular-review", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function getTabularReview( + reviewId: string, +): Promise { + return apiRequest(`/tabular-review/${reviewId}`); +} + +export async function updateTabularReview( + reviewId: string, + payload: { + title?: string; + columns_config?: { index: number; name: string; prompt: string }[]; + document_ids?: string[]; + project_id?: string | null; + shared_with?: string[]; + }, +): Promise { + return apiRequest(`/tabular-review/${reviewId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function getTabularReviewPeople( + reviewId: string, +): Promise { + return apiRequest(`/tabular-review/${reviewId}/people`); +} + +export async function generateTabularColumnPrompt( + title: string, + options?: { format?: string; documentName?: string; tags?: string[] }, +): Promise<{ prompt: string; source: "preset" | "llm" | "fallback" }> { + return apiRequest<{ + prompt: string; + source: "preset" | "llm" | "fallback"; + }>("/tabular-review/prompt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + format: options?.format, + documentName: options?.documentName, + tags: options?.tags, + }), + }); +} + +export async function uploadReviewDocument( + reviewId: string, + file: File, + options?: { + projectId?: string; + documentIds?: string[]; + columnsConfig?: { index: number; name: string; prompt: string }[]; + }, +): Promise { + const uploaded = options?.projectId + ? await uploadProjectDocument(options.projectId, file) + : await uploadStandaloneDocument(file); + + await updateTabularReview(reviewId, { + columns_config: options?.columnsConfig, + document_ids: [...(options?.documentIds ?? []), uploaded.id], + }); + + return uploaded; +} + +export async function deleteTabularReview(reviewId: string): Promise { + await apiRequest(`/tabular-review/${reviewId}`, { method: "DELETE" }); +} + +export async function streamTabularGeneration( + reviewId: string, +): Promise { + const authHeaders = await getAuthHeader(); + return clientConfig.fetchImpl( + apiUrl(`/tabular-review/${reviewId}/generate`), + { + method: "POST", + headers: { ...authHeaders }, + }, + ); +} + +/** + * Reconnect to an in-flight (or just-finished) generate run without + * re-triggering work. Used to resume tailing after the POST /generate stream + * drops. Emits the same `cell_update` frames and a final `[DONE]`; re-applying + * frames is idempotent (cells are matched by document_id + column_index). + */ +export async function resumeTabularGeneration( + reviewId: string, +): Promise { + const authHeaders = await getAuthHeader(); + return clientConfig.fetchImpl( + apiUrl(`/tabular-review/${reviewId}/generate/stream`), + { + method: "GET", + headers: { ...authHeaders }, + }, + ); +} + +export async function streamTabularChat( + reviewId: string, + messages: { role: string; content: string }[], + chat_id?: string | null, + signal?: AbortSignal, + context?: { reviewTitle?: string | null; projectName?: string | null }, +): Promise { + const authHeaders = await getAuthHeader(); + return clientConfig.fetchImpl(apiUrl(`/tabular-review/${reviewId}/chat`), { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders }, + body: JSON.stringify({ + messages, + chat_id: chat_id ?? undefined, + review_title: context?.reviewTitle ?? undefined, + project_name: context?.projectName ?? undefined, + }), + signal: signal ?? undefined, + }); +} + +export interface TRCitationAnnotation { + type: "tabular_citation"; + ref: number; + col_index: number; + row_index: number; + col_name: string; + doc_name: string; + quote: string; +} + +interface RawTRMessage { + id: string; + chat_id: string; + role: "user" | "assistant"; + content: string | AssistantEvent[] | null; + annotations?: TRCitationAnnotation[] | null; + created_at: string; +} + +export interface TRDisplayMessage { + role: "user" | "assistant"; + content: string; + events?: AssistantEvent[]; + annotations?: TRCitationAnnotation[]; +} + +export interface TRChat { + id: string; + title: string | null; + created_at: string; + updated_at: string; +} + +export function mapTRMessages(raw: RawTRMessage[]): TRDisplayMessage[] { + return raw.map((m) => { + if (m.role === "user") { + return { + role: "user" as const, + content: typeof m.content === "string" ? m.content : "", + }; + } + const events = Array.isArray(m.content) + ? (m.content as AssistantEvent[]) + : undefined; + const content = + events + ?.filter((e) => e.type === "content") + .map((e) => (e as { type: "content"; text: string }).text) + .join("") ?? ""; + return { + role: "assistant" as const, + content, + events, + annotations: m.annotations ?? undefined, + }; + }); +} + +export async function getTabularChats(reviewId: string): Promise { + return apiRequest(`/tabular-review/${reviewId}/chats`); +} + +export async function getTabularChatMessages( + reviewId: string, + chatId: string, +): Promise { + return apiRequest( + `/tabular-review/${reviewId}/chats/${chatId}/messages`, + ); +} + +export async function deleteTabularChat( + reviewId: string, + chatId: string, +): Promise { + await apiRequest(`/tabular-review/${reviewId}/chats/${chatId}`, { + method: "DELETE", + }); +} + +export async function regenerateTabularCell( + reviewId: string, + documentId: string, + columnIndex: number, +): Promise<{ + summary: string; + flag: "green" | "grey" | "yellow" | "red"; + reasoning: string; +}> { + return apiRequest(`/tabular-review/${reviewId}/regenerate-cell`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + document_id: documentId, + column_index: columnIndex, + }), + }); +} + +export async function clearTabularCells( + reviewId: string, + documentIds: string[], +): Promise { + await apiRequest(`/tabular-review/${reviewId}/clear-cells`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ document_ids: documentIds }), + }); +} + +// --------------------------------------------------------------------------- +// Workflows +// --------------------------------------------------------------------------- + +type WorkflowType = Workflow["metadata"]["type"]; + +export async function listWorkflows( + type: WorkflowType, +): Promise { + return apiRequest(`/workflows?type=${type}`); +} + +export async function getWorkflow(workflowId: string): Promise { + return apiRequest(`/workflows/${workflowId}`); +} + +export async function createWorkflow(payload: { + metadata: { + title: string; + type: "assistant" | "tabular"; + language?: string | null; + practice?: string | null; + jurisdictions?: string[] | null; + }; + skill_md?: string; + columns_config?: { index: number; name: string; prompt: string }[]; +}): Promise { + return apiRequest("/workflows", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function updateWorkflow( + workflowId: string, + payload: { + metadata?: { + title?: string; + language?: string | null; + practice?: string | null; + jurisdictions?: string[] | null; + }; + skill_md?: string; + columns_config?: { index: number; name: string; prompt: string }[]; + }, +): Promise { + return apiRequest(`/workflows/${workflowId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function deleteWorkflow(workflowId: string): Promise { + await apiRequest(`/workflows/${workflowId}`, { method: "DELETE" }); +} + +export async function openSourceWorkflow( + workflowId: string, + payload: { + contributor_mode: OpenSourceWorkflowContributorMode; + contributor?: WorkflowContributor | null; + }, +): Promise { + return apiRequest( + `/workflows/${workflowId}/open-source`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); +} + +export async function listHiddenWorkflows(): Promise { + return apiRequest("/workflows/hidden"); +} + +export async function hideWorkflow(workflowId: string): Promise { + await apiRequest("/workflows/hidden", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workflow_id: workflowId }), + }); +} + +export async function unhideWorkflow(workflowId: string): Promise { + await apiRequest(`/workflows/hidden/${workflowId}`, { method: "DELETE" }); +} + +export async function shareWorkflow( + workflowId: string, + payload: { emails: string[]; allow_edit: boolean }, +): Promise { + await apiRequest(`/workflows/${workflowId}/share`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function listWorkflowShares(workflowId: string): Promise< + { + id: string; + shared_with_email: string; + allow_edit: boolean; + created_at: string; + }[] +> { + return apiRequest(`/workflows/${workflowId}/shares`); +} + +export async function deleteWorkflowShare( + workflowId: string, + shareId: string, +): Promise { + await apiRequest(`/workflows/${workflowId}/shares/${shareId}`, { + method: "DELETE", + }); +} diff --git a/word-addin/src/vendor/core/apiKeyProviders.ts b/word-addin/src/vendor/core/apiKeyProviders.ts new file mode 100644 index 000000000..c47f745ee --- /dev/null +++ b/word-addin/src/vendor/core/apiKeyProviders.ts @@ -0,0 +1,57 @@ +export type ApiKeyProvider = + | "claude" + | "gemini" + | "openai" + | "openrouter" + | "courtlistener"; +export type ApiKeySource = "user" | "env" | null; + +declare const process: + | { env?: Record } + | undefined; + +function defaultEnv(): Record { + return typeof process === "undefined" ? {} : (process.env ?? {}); +} + +export const API_KEY_PROVIDERS = [ + "claude", + "gemini", + "openai", + "openrouter", + "courtlistener", +] as const satisfies readonly ApiKeyProvider[]; + +export function isApiKeyProvider(value: string): value is ApiKeyProvider { + return (API_KEY_PROVIDERS as readonly string[]).includes(value); +} + +export function normalizeApiKeyProvider(value: string): ApiKeyProvider | null { + return isApiKeyProvider(value) ? value : null; +} + +export function envApiKey( + provider: ApiKeyProvider, + env: Record = defaultEnv(), +): string | null { + if (provider === "claude") { + return env.ANTHROPIC_API_KEY?.trim() || env.CLAUDE_API_KEY?.trim() || null; + } + if (provider === "openai") { + return env.OPENAI_API_KEY?.trim() || null; + } + if (provider === "openrouter") { + return env.OPENROUTER_API_KEY?.trim() || null; + } + if (provider === "courtlistener") { + return env.COURTLISTENER_API_TOKEN?.trim() || null; + } + return env.GEMINI_API_KEY?.trim() || null; +} + +export function hasEnvApiKey( + provider: ApiKeyProvider, + env: Record = defaultEnv(), +): boolean { + return !!envApiKey(provider, env); +} diff --git a/word-addin/src/vendor/core/index.ts b/word-addin/src/vendor/core/index.ts new file mode 100644 index 000000000..f6c80bae3 --- /dev/null +++ b/word-addin/src/vendor/core/index.ts @@ -0,0 +1,3 @@ +export * from "./apiKeyProviders"; +export * from "./storagePaths"; +export * from "./types"; diff --git a/word-addin/src/vendor/core/storagePaths.ts b/word-addin/src/vendor/core/storagePaths.ts new file mode 100644 index 000000000..a6ed6c991 --- /dev/null +++ b/word-addin/src/vendor/core/storagePaths.ts @@ -0,0 +1,66 @@ +export function normalizeDownloadFilename(name: string): string { + const trimmed = name.trim(); + const base = trimmed || "download"; + return base.replace(/[\x00-\x1F\x7F]/g, "_").replace(/[\\/]/g, "_"); +} + +export function sanitizeDispositionFilename(name: string): string { + return normalizeDownloadFilename(name) + .replace(/["\\]/g, "_") + .replace(/[^\x20-\x7E]/g, "_"); +} + +export function encodeRFC5987(str: string): string { + return encodeURIComponent(str).replace( + /['()*]/g, + (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(), + ); +} + +export function buildContentDisposition( + kind: "inline" | "attachment", + filename: string, +): string { + const normalized = normalizeDownloadFilename(filename); + return `${kind}; filename="${sanitizeDispositionFilename(normalized)}"; filename*=UTF-8''${encodeRFC5987(normalized)}`; +} + +export function storageKey( + userId: string, + docId: string, + filename: string, +): string { + return `documents/${userId}/${docId}/source${storageExtension(filename, ".bin")}`; +} + +export function pdfStorageKey( + userId: string, + docId: string, + stem: string, +): string { + return `documents/${userId}/${docId}/${stem}.pdf`; +} + +export function generatedDocKey( + userId: string, + docId: string, + filename: string, +): string { + return `generated/${userId}/${docId}/generated${storageExtension(filename, ".docx")}`; +} + +export function versionStorageKey( + userId: string, + docId: string, + versionSlug: string, + filename: string, +): string { + return `documents/${userId}/${docId}/versions/${versionSlug}${storageExtension(filename, ".bin")}`; +} + +function storageExtension(filename: string, fallback: string): string { + const lastDot = filename.lastIndexOf("."); + if (lastDot < 0) return fallback; + const ext = filename.slice(lastDot).toLowerCase(); + return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : fallback; +} diff --git a/word-addin/src/vendor/core/types.ts b/word-addin/src/vendor/core/types.ts new file mode 100644 index 000000000..1e89e12f0 --- /dev/null +++ b/word-addin/src/vendor/core/types.ts @@ -0,0 +1,687 @@ +// Shared TypeScript types for Mike AI legal assistant + +export interface Folder { + id: string; + project_id: string; + user_id: string; + name: string; + parent_folder_id: string | null; + created_at: string; + updated_at: string; +} + +export interface LibraryFolder { + id: string; + user_id: string; + library_kind: "file" | "template"; + name: string; + parent_folder_id: string | null; + created_at: string; + updated_at: string; +} + +export interface Project { + id: string; + user_id: string; + is_owner?: boolean; + owner_display_name?: string | null; + owner_email?: string | null; + name: string; + cm_number: string | null; + practice: string | null; + shared_with: string[]; + created_at: string; + updated_at: string; + documents?: Document[]; + folders?: Folder[]; + document_count?: number; + chat_count?: number; + review_count?: number; +} + +export interface Document { + id: string; + user_id?: string; + project_id: string | null; + folder_id?: string | null; + library_kind?: "file" | "template"; + library_folder_id?: string | null; + filename: string; + owner_email?: string | null; + owner_display_name?: string | null; + file_type: string | null; // pdf | docx | doc | xlsx | xlsm | xls | pptx | ppt + storage_path: string | null; + pdf_storage_path: string | null; + size_bytes: number | null; + page_count: number | null; + structure_tree: StructureNode[] | null; + status: "pending" | "processing" | "ready" | "error"; + created_at: string | null; + updated_at?: string | null; + /** Version number of the document row pointed to by current_version_id. */ + active_version_number?: number | null; + /** Legacy: max version_number across assistant_edit rows, null if doc is unedited. */ + latest_version_number?: number | null; +} + +export interface StructureNode { + id: string; + title: string; + level: number; + page_number: number | null; + children: StructureNode[]; +} + +export interface Chat { + id: string; + project_id: string | null; + user_id: string; + creator_display_name?: string | null; + title: string | null; + created_at: string; +} + +export interface EditAnnotation { + type?: "edit_data"; + kind?: "edit"; + edit_id: string; + document_id: string; + version_id: string; + /** Per-document monotonic Vn for the edit's target version. */ + 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"; +} + +export type AssistantEvent = + | { type: "reasoning"; text: string; isStreaming?: boolean } + | { type: "error"; message: string } + | { + type: "tool_call_start"; + name: string; + isStreaming?: boolean; + } + | { + type: "mcp_tool_call"; + connector_id: string; + connector_name: string; + tool_name: string; + openai_tool_name: string; + status: "ok" | "error"; + error?: string; + isStreaming?: boolean; + } + | { + type: "ask_inputs"; + items: ( + | { + id: string; + kind: "choice"; + question: string; + options: { + value: string; + }[]; + allow_other: boolean; + other_label: string; + response_prefix?: string; + } + | { + id: string; + kind: "documents"; + document_types: string[]; + response_prefix?: string; + } + )[]; + } + | { + type: "ask_inputs_response"; + responses: ( + | { + id: string; + kind: "choice"; + question: string; + answer?: string; + skipped?: boolean; + } + | { + id: string; + kind: "documents"; + filenames: string[]; + skipped?: boolean; + } + )[]; + } + | { type: "thinking"; isStreaming?: boolean } + | { + type: "doc_read"; + filename: string; + document_id?: string; + isStreaming?: boolean; + } + | { + type: "doc_find"; + filename: string; + query: string; + total_matches: number; + isStreaming?: boolean; + } + | { + type: "doc_created"; + filename: string; + download_url: string; + /** Set when the generated doc is persisted as a first-class document. */ + document_id?: string; + version_id?: string; + version_number?: number | null; + isStreaming?: boolean; + } + | { type: "doc_download"; filename: string; download_url: string } + | { + type: "doc_replicated"; + /** Source document filename. */ + filename: string; + /** How many copies were produced in this single tool call. */ + count: number; + /** One entry per new copy. Empty while streaming. */ + copies?: { + new_filename: string; + document_id: string; + version_id: string; + }[]; + error?: string; + isStreaming?: boolean; + } + | { type: "workflow_applied"; workflow_id: string; title: string } + | { + type: "doc_edited"; + filename: string; + document_id: string; + version_id: string; + /** Per-document monotonic Vn written at emit time. */ + version_number?: number | null; + download_url: string; + annotations: EditAnnotation[]; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_search_case_law"; + query: string; + result_count?: number; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_get_cases"; + cluster_ids: number[]; + case_count?: number; + opinion_count?: number; + cases?: { + cluster_id: number; + case_name: string | null; + citation: string | null; + dateFiled?: string | null; + url?: string | null; + }[]; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_find_in_case"; + cluster_id: number | null; + query: string; + total_matches?: number; + case_name?: string | null; + citation?: string | null; + searches?: { + cluster_id: number | null; + query: string; + total_matches?: number; + case_name?: string | null; + citation?: string | null; + error?: string; + }[]; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_read_case"; + cluster_id: number | null; + case_name?: string | null; + citation?: string | null; + opinion_count?: number; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_verify_citations"; + citation_count?: number; + match_count?: number; + error?: string; + isStreaming?: boolean; + } + | { + type: "case_citation"; + cluster_id: number | null; + case_name: string | null; + citation: string | null; + url: string; + pdfUrl?: string | null; + dateFiled?: string | null; + case?: Extract["case"]; + } + | { + type: "case_opinions"; + cluster_id: number; + case: { + id: number | null; + caseName?: string | null; + dateFiled?: string | null; + citations?: string[]; + url?: string | null; + pdfUrl?: string | null; + opinions: { + opinionId: number | null; + apiUrl?: string | null; + type: string | null; + author: string | null; + url: string | null; + text?: string | null; + html?: string | null; + }[]; + }; + } + | { type: "content"; text: string; isStreaming?: boolean }; + +export type CaseCitationQuote = { + opinionId: number | null; + type: string | null; + author: string | null; + quote: string; +}; + +export interface Message { + id?: string; + role: "user" | "assistant"; + content: string; + files?: { filename: string; document_id?: string }[]; + workflow?: { id: string; title: string }; + model?: string; + citations?: Citation[]; + citationStatus?: "started" | "partial" | "final"; + events?: AssistantEvent[]; + /** Set when streaming failed; rendered as a red error block. */ + error?: string; +} + +export interface CitationQuote { + page?: number; + quote: string; +} + +/** + * Result of server-side verification of a document quote against the extracted + * source text. + * - `verified` — the quote was found and is character-identical to the source. + * - `repaired` — the quote was found under whitespace/case/punctuation-tolerant + * matching but drifted from the source; `source_excerpt` holds the + * exact source text and has been swapped into the displayed quote. + * - `unverified` — no tolerant match; the model's quote is preserved but untrusted. + * + * A MISSING status (undefined) must be treated as untrusted by the UI — some + * paths (tabular, abort/error persistence) do not run verification. + */ +export type CitationVerificationStatus = "verified" | "unverified" | "repaired"; + +/** + * Per-quote verification result. `start_char`/`end_char` index into the + * EXTRACTED source text (not the raw file bytes) and are only present for + * single-segment quotes that matched. + */ +export type QuoteVerification = { + status: CitationVerificationStatus; + start_char?: number; + end_char?: number; + source_excerpt?: string; +}; + +export type DocumentCitationQuote = { + page: number | string; + quote: string; + /** + * Spreadsheet citations are located by cell, not page: `sheet` is the + * worksheet name and `cell` is an A1 address or range (e.g. "B7", "B7:C9"). + */ + sheet?: string; + cell?: string; + verification?: QuoteVerification; +}; + +export type DocumentCitation = { + type: "citation_data"; + kind?: "document"; + ref: number; + doc_id: string; + document_id: string; + version_id?: string | null; + version_number?: number | null; + filename: string; + /** Legacy single-quote fields. Prefer `quotes` for new citations. */ + page: number | string; + quote: string; + sheet?: string; + cell?: string; + quotes?: DocumentCitationQuote[]; + /** + * Aggregate verification over all quotes: `unverified` if any quote is + * unverified, else `repaired` if any is repaired, else `verified`. Absent on + * annotations produced by paths that skip verification. + */ + verification_status?: CitationVerificationStatus; +}; + +export type CaseCitation = { + type: "citation_data"; + kind: "case"; + ref: number; + cluster_id: number; + case_name?: string | null; + citation?: string | null; + url?: string | null; + pdfUrl?: string | null; + dateFiled?: string | null; + quotes: CaseCitationQuote[]; +}; + +/** + * A citation emitted by the assistant. Document citations have doc/page + * anchors. Case citations anchor to a CourtListener cluster and include a + * quoted opinion passage. + */ +export type Citation = + | DocumentCitation + | CaseCitation; + +const PAGE_BREAK_SENTINEL = "[[PAGE_BREAK]]"; + +export function isSpreadsheetFilename(filename: string): boolean { + const ext = filename.split(".").pop()?.toLowerCase(); + return ext === "xlsx" || ext === "xlsm" || ext === "xls"; +} + +export function isDocxFilename(filename: string): boolean { + const ext = filename.split(".").pop()?.toLowerCase(); + return ext === "docx" || ext === "doc"; +} + +/** + * Human-readable cell locator for a spreadsheet citation, e.g. "Sheet1!B7". + * Falls back to whichever of `sheet`/`cell` is present. + */ +function formatCellLocator(sheet?: string, cell?: string): string { + if (sheet && cell) return `${sheet}!${cell}`; + return cell ?? sheet ?? ""; +} + +/** + * Reader-friendly cell locator, e.g. "Sheet1, cell B7" (or "cells B7:C9" for a + * range). Unlike `formatCellLocator`, this avoids the Excel `!` notation, which + * reads poorly in prose. Used for the single-quote detail shown to the reader; + * the machine-style `Sheet1!B7` form is kept where locators are joined together. + */ +function formatCellLocatorReadable(sheet?: string, cell?: string): string { + if (!cell) return sheet ?? ""; + const cellWord = cell.includes(":") ? "cells" : "cell"; + const cellPart = `${cellWord} ${cell}`; + return sheet ? `${sheet}, ${cellPart}` : cellPart; +} + +/** `{sheet, cell}` locators for a citation's quotes (spreadsheet sources). */ +export function getCitationCells( + a: Citation, +): { sheet?: string; cell?: string }[] { + if (a.kind === "case") return []; + return getDocumentCitationQuotes(a) + .filter((q) => q.cell || q.sheet) + .map((q) => ({ sheet: q.sheet, cell: q.cell })); +} + +function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[] { + const rangeMatch = + typeof entry.page === "string" + ? entry.page.match(/^(\d+)\s*-\s*(\d+)$/) + : null; + if (rangeMatch && entry.quote.includes(PAGE_BREAK_SENTINEL)) { + const startPage = parseInt(rangeMatch[1], 10); + const endPage = parseInt(rangeMatch[2], 10); + const [before, after] = entry.quote.split(PAGE_BREAK_SENTINEL); + return [ + { page: startPage, quote: before.trim() }, + { page: endPage, quote: after.trim() }, + ].filter((e) => e.quote.length > 0); + } + const pageNum = + typeof entry.page === "number" + ? entry.page + : parseInt(String(entry.page), 10); + if (!Number.isFinite(pageNum)) return []; + return [{ page: pageNum, quote: entry.quote }]; +} + +export function getDocumentCitationQuotes( + a: Citation, +): DocumentCitationQuote[] { + if (a.kind === "case") return []; + if (Array.isArray(a.quotes) && a.quotes.length) { + return a.quotes.filter((entry) => entry.quote.trim().length > 0); + } + return [{ page: a.page, quote: a.quote, sheet: a.sheet, cell: a.cell }]; +} + +/** + * Expand a citation into one or more (page, quote) entries suitable for + * highlighting in the PDF viewer. A single-page citation yields one entry; a + * cross-page citation with page "N-M" and a `[[PAGE_BREAK]]` split yields two. + */ +export function expandCitationToEntries( + a: Citation, +): CitationQuote[] { + if (a.kind === "case") return []; + return getDocumentCitationQuotes(a).flatMap(expandDocumentQuoteEntry); +} + +/** + * Format the page(s) of a citation for display, e.g. "Page 3" or "Page 41-42". + * Spreadsheets have no meaningful page locator, so this returns "" for them — + * callers join with `.filter(Boolean)` so the locator is simply omitted. + */ +export function formatCitationPage(a: Citation): string { + if (a.kind === "case") { + return a.citation || a.case_name || `Case ${a.cluster_id}`; + } + const quotes = getDocumentCitationQuotes(a); + // Spreadsheets are located by cell, e.g. "Sheet1!B7" (or several). + if (isSpreadsheetFilename(a.filename)) { + const cells = Array.from( + new Set( + quotes.map((q) => formatCellLocator(q.sheet, q.cell)).filter(Boolean), + ), + ); + return cells.join(", "); + } + const pages = Array.from( + new Set(quotes.map((q) => String(q.page)).filter(Boolean)), + ); + if (pages.length > 1) return `Pages ${pages.join(", ")}`; + if (pages.length === 1) return `Page ${pages[0]}`; + return `Page ${a.page}`; +} + +/** + * Aggregate verification status of a citation, or `undefined` when unknown. + * Case-law citations are existence-verified upstream (CourtListener) and are + * never re-marked here, so this always returns `undefined` for them — callers + * must not present a document quote with an `undefined` status as trusted. + */ +export function citationVerificationStatus( + a: Citation, +): CitationVerificationStatus | undefined { + if (a.kind === "case") return undefined; + return a.verification_status; +} + +/** Locator label for a single quote — "Page 3" for docs, "Sheet1, cell B7" for cells. */ +export function formatCitationQuotePage( + a: Citation, + page: number | string, + quote?: DocumentCitationQuote, +): string { + if (a.kind !== "case" && isSpreadsheetFilename(a.filename)) { + return formatCellLocatorReadable(quote?.sheet, quote?.cell); + } + return `Page ${page}`; +} + +/** + * Reader-friendly version of a single raw quote: replaces [[PAGE_BREAK]] with + * "...". Spreadsheet quotes now carry plain cell values, so no stripping. + */ +export function cleanCitationQuoteText( + _a: Citation, + rawQuote: string, +): string { + return rawQuote.replaceAll(PAGE_BREAK_SENTINEL, "..."); +} + +/** Produce a reader-friendly version of the quote (replaces [[PAGE_BREAK]] with "..."). */ +export function displayCitationQuote(a: Citation): string { + if (a.kind === "case") { + return a.quotes + .map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "...")) + .join(" / "); + } + return getDocumentCitationQuotes(a) + .map((q) => cleanCitationQuoteText(a, q.quote)) + .filter(Boolean) + .join(" / "); +} + +// Tabular Review + +export type ColumnFormat = + | "text" + | "bulleted_list" + | "number" + | "currency" + | "yes_no" + | "date" + | "tag" + | "percentage" + | "monetary_amount"; + +export interface ColumnConfig { + index: number; + name: string; + prompt: string; + format?: ColumnFormat; + tags?: string[]; +} + +export interface TabularReview { + id: string; + project_id: string | null; + user_id: string; + title: string | null; + columns_config: ColumnConfig[] | null; + document_ids?: string[] | null; + workflow_id: string | null; + practice?: string | null; + /** Per-review email list. Used so standalone (project_id null) reviews can be shared directly. */ + shared_with?: string[]; + /** Server-set: true when the requesting user is the review's creator. */ + is_owner?: boolean; + created_at: string; + updated_at: string; + document_count?: number; +} + +export interface TabularCell { + id: string; + review_id: string; + document_id: string; + column_index: number; + content: { + summary: string; + flag?: "green" | "grey" | "yellow" | "red"; + reasoning?: string; + } | null; + status: "pending" | "generating" | "done" | "error"; + created_at: string; +} + +// Workflows + +export interface WorkflowOpenSourceSubmission { + id: string; + status: "pending" | "approved" | "rejected"; + submitted_at: string; + updated_at: string; + reviewed_at?: string | null; +} + +export interface OpenSourceWorkflowResponse + extends WorkflowOpenSourceSubmission { + mode: "created" | "updated"; +} + +export type OpenSourceWorkflowContributorMode = "named" | "anonymous"; + +export interface WorkflowContributor { + name: string; + organisation: string | null; + role: string | null; + linkedin: string | null; +} + +export interface Workflow { + id: string; + user_id: string | null; + metadata: { + title: string; + description: string | null; + type: "assistant" | "tabular"; + contributors: WorkflowContributor[]; + language: string; + version: string | null; + practice: string | null; + jurisdictions: string[] | null; + }; + skill_md: string | null; + columns_config: ColumnConfig[] | null; + is_system: boolean; + created_at: string; + shared_by_name?: string | null; + allow_edit?: boolean; + is_owner?: boolean; + open_source_submission?: WorkflowOpenSourceSubmission | null; +} + +// API helpers + +export interface ChatDetailOut { + chat: Chat; + messages: Message[]; +} + +export interface TabularReviewDetailOut { + review: TabularReview; + cells: TabularCell[]; + documents: Document[]; +} diff --git a/word-addin/src/vendor/shared/chat/ChatBubble.tsx b/word-addin/src/vendor/shared/chat/ChatBubble.tsx new file mode 100644 index 000000000..6722e48c9 --- /dev/null +++ b/word-addin/src/vendor/shared/chat/ChatBubble.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; + +import { cn } from "../lib/utils"; +import { Markdown } from "./Markdown"; + +/** + * Right-aligned user message bubble. Visual style matches the web app's + * UserMessage (soft rounded bubble). + */ +export function UserBubble({ + content, + className, +}: { + content: string; + className?: string; +}) { + return ( +
+
+

+ {content} +

+
+
+ ); +} + +/** + * Left-aligned assistant message. Renders markdown content and an optional + * `actions` row (e.g. the Word add-in's Insert / tracked-change buttons). + */ +export function AssistantBubble({ + content, + actions, + className, +}: { + content: string; + actions?: React.ReactNode; + className?: string; +}) { + return ( +
+ {content} + {actions && ( +
{actions}
+ )} +
+ ); +} diff --git a/word-addin/src/vendor/shared/chat/ChatInput.tsx b/word-addin/src/vendor/shared/chat/ChatInput.tsx new file mode 100644 index 000000000..157ac2aca --- /dev/null +++ b/word-addin/src/vendor/shared/chat/ChatInput.tsx @@ -0,0 +1,101 @@ +"use client"; + +import * as React from "react"; +import { ArrowUp, Square } from "lucide-react"; + +import { cn } from "../lib/utils"; + +interface ChatInputProps { + value: string; + onValueChange: (value: string) => void; + onSubmit: () => void; + isLoading?: boolean; + onCancel?: () => void; + placeholder?: string; + disabled?: boolean; + /** Accessory controls rendered on the left of the action row (e.g. a toggle). */ + leftSlot?: React.ReactNode; + className?: string; +} + +/** + * Presentational chat composer shell shared across surfaces. A rounded + * textarea with a send / stop button, matching the web app's composer look, + * laid out to fit a narrow task-pane column. Enter submits; Shift+Enter + * inserts a newline. + */ +export function ChatInput({ + value, + onValueChange, + onSubmit, + isLoading = false, + onCancel, + placeholder = "Ask Mike…", + disabled = false, + leftSlot, + className, +}: ChatInputProps) { + const textareaRef = React.useRef(null); + + // Auto-grow the textarea up to a max height. + React.useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "0px"; + el.style.height = `${Math.min(el.scrollHeight, 160)}px`; + }, [value]); + + const handleKeyDown = ( + e: React.KeyboardEvent + ): void => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (value.trim() && !isLoading && !disabled) onSubmit(); + } + }; + + const canSend = !!value.trim() && !isLoading && !disabled; + + return ( +
+