diff --git a/docs/js-backend-plan.md b/docs/js-backend-plan.md index 9d7f60e8..a0718c7f 100644 --- a/docs/js-backend-plan.md +++ b/docs/js-backend-plan.md @@ -1,6 +1,6 @@ # JS Backend Plan (under evaluation) -An alternative to `docs/auth-plan.md`. Same problem (replace Auth0, gain a user store, host the AI proxy), different shape: rewrite the production backend in TypeScript, use **Better-Auth** instead of building auth from primitives. +An alternative to `docs/auth-plan.md`. Same problem (replace Auth0, gain a user store, host the AI proxy), different shape: replace the production backend with a small **Hono** (TypeScript) service that uses **Better-Auth** instead of building auth from primitives. This plan is **under evaluation alongside** `docs/auth-plan.md`. Stage 0 (proof of concept) is the gate: if it works, we commit to this plan and shelve `auth-plan.md`; if it doesn't, we fall back to the FastAPI route. @@ -8,9 +8,24 @@ This plan is **under evaluation alongside** `docs/auth-plan.md`. Stage 0 (proof - The LLM endpoints are migrating to ai-sdk in a separate PR, shrinking the Python backend to "proxy + auth + log writes." - Better-Auth handles ~80% of `auth-plan.md`'s scope (sessions, OAuth handlers, account management, deletion, export) as a library, instead of as code we maintain. -- ai-sdk on the server side eliminates the proxy layer entirely — the Next.js route handler *is* the model call. +- ai-sdk on the server side eliminates the proxy layer entirely — the Hono route handler *is* the model call. - A single language across frontend and the slimmed backend lets `ModelMessage` and similar types be shared (eventually). +## Why Hono, not Next.js, for the backend + +The backend has **no UI** beyond one throwaway OAuth landing page ("signing you in… you can close this tab"). That removes the reason to reach for a UI/SSR framework: + +- **App Router's premise is inert here.** RSC/App Router exists to server-render initial content and stream server data into the first paint. This service renders no app UI, and the add-in itself boots inside Word/GDocs where there is no server-rendered first paint to optimize. We'd adopt Next's whole conceptual surface (RSC, the server/client boundary, Turbopack, the build model) to use ~none of it. +- **Hono is "FastAPI, in TypeScript."** A minimal router + middleware, no bundler, no SSR build step. Prod-path shape stays nearly identical to today (one service serving the add-in's API + a couple of static assets), just TS instead of Python. +- **Better-Auth and ai-sdk are both first-class on Hono.** Better-Auth mounts as a single catch-all handler; ai-sdk's `streamText(...).toUIMessageStreamResponse()` returns a standard `Response` that Hono returns directly. +- **Runtime-portable.** The same code runs on Node, Bun, or Deno, so hosting isn't pinned to a Next-shaped deploy target. + +**Next.js stays the fallback:** if Stage 0 surfaces a Better-Auth/device-code gap that Hono makes hard but Next's ecosystem makes easy, we swap the backend framework without changing the plan's shape. + +### A note on "single origin" + +Splitting the SPA frontend from this backend does **not** force two web origins. The Office manifest already pins fixed URLs and we already front things with nginx, so the built SPA and the Hono service can sit behind one origin (Hono can even serve the static bundle itself; in dev, Vite's `server.proxy` forwards `/api` and `/auth` to Hono). There is no CORS tax inherent to the split — and `/api/*` uses bearer tokens, not cookies, so cross-site cookie rules don't apply even if we did split origins. + ## Non-coupling decisions - **Not** sharing code with `experiment/` right now. New sibling app. Re-evaluate sharing later, after both stabilize. @@ -21,7 +36,7 @@ This plan is **under evaluation alongside** `docs/auth-plan.md`. Stage 0 (proof ``` frontend/ # unchanged: TS/React Office add-in + GDocs sidebar + standalone -backend-next/ # new: Next.js (or Hono) app +backend-ts/ # new: Hono service (Node/Bun); serves add-in API + auth - Better-Auth (Google OAuth, SQLite via Drizzle) - Device-code login shell (sidebar can't iframe Google) - /api/openai/chat/completions (ai-sdk passthrough) @@ -41,13 +56,14 @@ The JS backend and Python backend share **only the log files on disk** (JSONL) a ### Scope -Create a sibling app at `backend-next/`: +Create a sibling app at `backend-ts/`: -- Next.js 15 app (App Router) — `app/` route handlers only, no UI pages except the OAuth landing page. +- Hono app (Node or Bun runtime) — route handlers only; the *only* rendered HTML is the OAuth landing / "close this tab" page (`c.html(...)` or a static file). - Better-Auth configured with: - Google provider - Drizzle adapter - - SQLite (file in `backend-next/.data/poc.db`, gitignored) + - SQLite (file in `backend-ts/.data/poc.db`, gitignored) + - Mounted on Hono as a catch-all handler: `app.on(['POST','GET'], '/api/auth/**', (c) => auth.handler(c.req.raw))`. - Device-code wrapper, ~3 endpoints: - `POST /auth/device/start` — create `pending_logins` row, return `{ device_code, login_url }` - `GET /auth/login?device_code=…` — render a tiny page that calls Better-Auth's `signIn.social({ provider: 'google', callbackURL: '/auth/device/complete?device_code=…' })` @@ -57,16 +73,16 @@ Create a sibling app at `backend-next/`: - Reads `Authorization: Bearer `, validates with Better-Auth - Calls `streamText({ model: openai('gpt-4o'), messages })` and returns the result with `toUIMessageStreamResponse()` *or* a raw OpenAI-compatible SSE stream (try the AI SDK protocol first since the frontend was already migrated to ai-sdk in PR #433) - JSONL log write on each `/api/openai/*` call. Same `Log` shape as `backend/server.py`, written to `logs/poc.jsonl`. -- **Tiny test harness**, not the real add-in: a single static HTML file in `backend-next/poc-client/` that runs the device-code flow, stores the token in `localStorage`, and calls the protected streaming endpoint. We hit this URL from inside Word's task pane and GDocs sidebar manually. +- **Tiny test harness**, not the real add-in: a single static HTML file in `backend-ts/poc-client/` that runs the device-code flow, stores the token in `localStorage`, and calls the protected streaming endpoint. We hit this URL from inside Word's task pane and GDocs sidebar manually. ### What we are explicitly validating 1. We can open the `login_url` as a new tab in the user's main browser, even when the add-in is loaded inside of the Word desktop app. In the GDocs sidebar, we can either open a new tab or pop up a dialog. 2. Polling completes within a few seconds of Google consent — no weird state where the browser tab finishes but the sidebar never sees the session. -3. Better-Auth's session cookie is accessible from a server-side handler (`/auth/device/complete`) that runs in the *same* request chain as its callback, so we can stamp the `device_code → session` link without forking Better-Auth's internals. +3. Better-Auth's session cookie is accessible from a Hono handler (`/auth/device/complete`) that runs in the *same* request chain as its callback, so we can stamp the `device_code → session` link without forking Better-Auth's internals. 4. The streaming response works end-to-end: `streamText` server-side → SSE → ai-sdk client-side, with `Authorization` header preserved across the streaming fetch. 5. JSONL append survives concurrent writes (a Python `fsspec`-style append, or `fs.appendFile` in Node — either should be fine but worth proving once). -6. The whole thing runs locally with `pnpm dev` or `bun dev` without dragging in webpack-level pain. +6. The whole thing runs locally with `bun dev` (or `tsx watch`) — no bundler/SSR build step at all. ### Exit criteria @@ -75,19 +91,19 @@ Stage 0 passes if **all six** of the above work in a 30-minute manual test sessi Stage 0 fails (and we fall back to `auth-plan.md`) if any of: - Better-Auth's session-cookie / device-code linkage requires forking the library or unsupported APIs. - `window.open` is consistently blocked in Word or GDocs and there's no clean fallback. -- The deploy story for Next.js on the existing infra is materially worse than redeploying FastAPI (e.g., requires moving off the current host). +- The deploy story for the Hono service on the existing infra is materially worse than redeploying FastAPI (e.g., requires moving off the current host). ### Stage 0 deliverables -- `backend-next/` directory committed to a branch, runnable with one command. -- `backend-next/README.md` with manual test steps. +- `backend-ts/` directory committed to a branch, runnable with one command. +- `backend-ts/README.md` with manual test steps. - A short writeup in this doc (appended below as "Stage 0 results") documenting what worked, what didn't, and the go/no-go decision. ## Stage 1+ (sketch only; revisit after Stage 0) Only fill this in once Stage 0 passes. -- **Stage 1:** Port the AI-SDK-migrated chat/revise endpoints from `backend/server.py` to `backend-next/`. Frontend points at the new host for `/api/openai/*`. FastAPI stops serving those routes. +- **Stage 1:** Port the AI-SDK-migrated chat/revise endpoints from `backend/server.py` to `backend-ts/`. Frontend points at the new host for `/api/openai/*`. FastAPI stops serving those routes. - **Stage 2:** Wire the real frontend `useSession()` to Better-Auth via the device-code shell. Remove `@auth0/auth0-react`. - **Stage 3:** Port `/api/get_suggestion` and `/api/reflections` after the separate ai-sdk migration PR lands. - **Stage 4:** Account deletion + export endpoints (Better-Auth has primitives for these; thin wrappers). @@ -110,10 +126,10 @@ Only fill this in once Stage 0 passes. ## Open Questions -- **Hosting:** where does the Next.js app run in production? Same host as FastAPI, sidecar, or separate? Affects Stage 0's "deploy story" exit criterion. -- **Runtime:** Node, Bun, or Deno? Default to Node for boring-tech reasons unless Bun's DX wins materially in Stage 0. +- **Hosting:** where does the Hono service run in production? Same host as FastAPI, sidecar, or separate? Affects Stage 0's "deploy story" exit criterion. +- **Runtime:** Node, Bun, or Deno? Default to Node for boring-tech reasons unless Bun's DX wins materially in Stage 0 (Hono runs on all three unchanged). - **Drizzle vs Prisma:** Better-Auth supports both. Lean Drizzle (lighter, less codegen) unless the POC reveals an issue. -- **SSE vs AI SDK data-stream protocol on the wire:** the frontend was migrated to ai-sdk's client in PR #433 and consumes either format. Pick whichever Better-Auth + Next.js route handlers make easier in Stage 0. +- **SSE vs AI SDK data-stream protocol on the wire:** the frontend was migrated to ai-sdk's client in PR #433 and consumes either format. Pick whichever Better-Auth + Hono make easier in Stage 0. ## Stage 0 Results diff --git a/frontend/.gitignore b/frontend/.gitignore index 26cb07ad..2c89c307 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,11 +1,37 @@ +# dependencies +node_modules/ +/.pnp +.pnp.* + +# next.js +/.next/ +/out/ -# Build output +# build output (webpack legacy + next standalone) dist/ +/build -# Playwright -node_modules/ +# testing / coverage +/coverage /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /playwright/.auth/ + +# misc +.DS_Store +*.pem +*.tsbuildinfo +next-env.d.ts + +# debug +npm-debug.log* +yarn-debug.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel diff --git a/frontend/app/api/chat/route.ts b/frontend/app/api/chat/route.ts new file mode 100644 index 00000000..2ab23b9e --- /dev/null +++ b/frontend/app/api/chat/route.ts @@ -0,0 +1,27 @@ +import { convertToModelMessages, type ModelMessage, type UIMessage } from 'ai'; +import { streamChat } from '@/lib/ai'; +import { defaultModel } from '@/lib/models'; +import { buildChatDocContextMessage } from '@/lib/prompts'; +import type { DocContext } from '@/lib/types'; + +export async function POST(req: Request) { + const { messages, docContext, system } = (await req.json()) as { + messages: UIMessage[]; + docContext?: DocContext; + system?: string; + }; + + // Prepend the current document context (sent fresh with each turn) ahead of the + // conversation so the assistant can see what the writer is working on. + const contextMessages: ModelMessage[] = docContext + ? [{ role: 'user', content: buildChatDocContextMessage(docContext) }] + : []; + + const result = streamChat({ + model: defaultModel(), + messages: [...contextMessages, ...convertToModelMessages(messages)], + system, + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/frontend/app/api/draft/route.ts b/frontend/app/api/draft/route.ts new file mode 100644 index 00000000..28de074f --- /dev/null +++ b/frontend/app/api/draft/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { generateSuggestion } from '@/lib/ai'; +import { defaultModel } from '@/lib/models'; +import type { DocContext } from '@/lib/types'; + +export async function POST(req: Request) { + const { type, docContext } = (await req.json()) as { + type: string; + docContext: DocContext; + }; + + const generation = await generateSuggestion({ + model: defaultModel(), + type, + docContext, + }); + + return NextResponse.json(generation); +} diff --git a/frontend/app/api/revise/route.ts b/frontend/app/api/revise/route.ts new file mode 100644 index 00000000..9fb35c24 --- /dev/null +++ b/frontend/app/api/revise/route.ts @@ -0,0 +1,19 @@ +import { streamRevision } from '@/lib/ai'; +import { defaultModel } from '@/lib/models'; +import type { DocContext } from '@/lib/types'; + +export async function POST(req: Request) { + const { docContext, request: visualizationRequest } = (await req.json()) as { + docContext: DocContext; + request: string; + }; + + const result = streamRevision({ + model: defaultModel(), + docContext, + request: visualizationRequest, + }); + + // The Revise UI consumes a raw text stream of deltas. + return result.toTextStreamResponse(); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 00000000..93ac09d0 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,47 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); +} + +html, +body { + height: 100%; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} + +/* Thin scrollbar used by the editor surface (ported from the legacy editor styles). */ +@layer base { + .editor-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.1) transparent; + } + + .editor-scrollbar::-webkit-scrollbar { + width: 4px; + height: 4px; + } + .editor-scrollbar::-webkit-scrollbar-track { + background: transparent; + margin-right: -4px; + } + .editor-scrollbar::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + margin-right: -4px; + } + .editor-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.4); + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 00000000..8c3ea0af --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Provider } from "jotai"; +import Providers from "@/components/Providers"; + +export const metadata: Metadata = { + title: "Thoughtful", + description: "An AI writing assistant that helps you think, not write for you.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 00000000..d590f095 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +// The editor uses Lexical and localStorage, so render it client-only (no SSR). +const StandaloneEditor = dynamic(() => import('@/components/editor/StandaloneEditor'), { + ssr: false, + loading: () =>
Loading editor…
, +}); + +export default function Home() { + return ; +} diff --git a/frontend/app/taskpane/page.tsx b/frontend/app/taskpane/page.tsx new file mode 100644 index 00000000..90e66616 --- /dev/null +++ b/frontend/app/taskpane/page.tsx @@ -0,0 +1,14 @@ +// Word task pane surface. Reached via the `/taskpane.html` -> `/taskpane` rewrite that +// the Office manifest points at. This placeholder is replaced by the Office.js-backed +// editor app (onReady gating + wordEditorAPI) in a later migration commit. +export default function TaskPane() { + return ( +
+

Thoughtful

+

+ Task pane scaffold. Office.js integration lands in a subsequent migration + commit. +

+
+ ); +} diff --git a/frontend/babel.config.json b/frontend/babel.config.json deleted file mode 100644 index aed7d347..00000000 --- a/frontend/babel.config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "presets": [ - [ - "@babel/preset-env", - { - "targets": { - "esmodules": false - } - } - ], - "@babel/preset-typescript" - ] - } \ No newline at end of file diff --git a/frontend/components/App.tsx b/frontend/components/App.tsx new file mode 100644 index 00000000..69697f34 --- /dev/null +++ b/frontend/components/App.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useAtomValue } from 'jotai'; +import { PageName, pageNameAtom } from '@/contexts/pageContext'; +import Navbar from './Navbar'; +import Chat from './pages/Chat'; +import Draft from './pages/Draft'; +import Revise from './pages/Revise'; + +function getComponent(pageName: PageName) { + switch (pageName) { + case PageName.Revise: + return ; + case PageName.Chat: + return ; + case PageName.Draft: + return ; + } +} + +// The sidebar app: the tab bar plus the active panel. Surfaces (standalone / Word) supply +// the EditorContext that the panels read from. +export default function App() { + const page = useAtomValue(pageNameAtom); + + return ( +
+ +
{getComponent(page)}
+
+ ); +} diff --git a/frontend/src/components/navbar/styles.module.css b/frontend/components/Navbar.module.css similarity index 100% rename from frontend/src/components/navbar/styles.module.css rename to frontend/components/Navbar.module.css diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx new file mode 100644 index 00000000..185e477c --- /dev/null +++ b/frontend/components/Navbar.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useAtom } from 'jotai'; +import { PageName, pageNameAtom } from '@/contexts/pageContext'; +import classes from './Navbar.module.css'; + +type Page = { + name: PageName; + title: string; + hint: string; +}; + +const pageNames: Page[] = [ + { name: PageName.Draft, title: 'Draft', hint: 'Generate suggestions' }, + { name: PageName.Revise, title: 'Revise', hint: 'Improve your text' }, + { name: PageName.Chat, title: 'Chat', hint: 'Ask about your doc' }, +]; + +export default function Navbar() { + const [page, changePage] = useAtom(pageNameAtom); + + return ( +
+ {pageNames.map(({ name: pageName, title: pageTitle, hint }) => ( + + ))} +
+ ); +} diff --git a/frontend/components/Providers.tsx b/frontend/components/Providers.tsx new file mode 100644 index 00000000..5bb51e0f --- /dev/null +++ b/frontend/components/Providers.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react'; +import { Button, Reshaped } from 'reshaped'; +import 'reshaped/themes/slate/theme.css'; +import ChatContextWrapper from '@/contexts/chatContext'; + +// PostHog configuration - project token is safe to commit publicly +const POSTHOG_KEY = 'phc_p3Br0zRnw7PdTVpdNI92vvBTWcBBY0jvkHO8dNvkCTl'; +const POSTHOG_HOST = 'https://e.thoughtful-ai.com/'; + +function PostHogErrorFallback() { + return ( +
+

Something went wrong

+

An error has been logged. Please refresh the page.

+ +
+ ); +} + +// App-wide client providers: analytics (PostHog), the Reshaped theme, and the chat +// message context that persists across tab switches. (Jotai's Provider lives in the root +// layout.) +export default function Providers({ children }: { children: React.ReactNode }) { + return ( + + }> + + {children} + + + + ); +} diff --git a/frontend/components/editor/LexicalEditor.tsx b/frontend/components/editor/LexicalEditor.tsx new file mode 100644 index 00000000..de93fe2b --- /dev/null +++ b/frontend/components/editor/LexicalEditor.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; +import { + type InitialEditorStateType, + LexicalComposer, +} from '@lexical/react/LexicalComposer'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { + $getRoot, + $getSelection, + $isRangeSelection, + type ElementNode, + type LexicalNode, +} from 'lexical'; +import type { ReactNode } from 'react'; +import type { DocContext } from '@/lib/types'; +import classes from './editor.module.css'; + +function $getDocContext(): DocContext { + // Initialize default empty context + const docContext: DocContext = { + beforeCursor: '', + selectedText: '', + afterCursor: '', + }; + + // Get current selection + const selection = $getSelection(); + + // If no valid range selection exists, return empty context + if (!$isRangeSelection(selection)) { + return docContext; + } + + // Get selected text content + docContext.selectedText = selection.getTextContent(); + + // Get points for traversal + let anchor = selection.anchor; + let focus = selection.focus; + + // If the selection is backward, we need to swap the anchor and focus points. + if (selection.isBackward()) { + const temp = anchor; + anchor = focus; + focus = temp; + } + + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + const anchorOffset = anchor.offset; + const focusOffset = focus.offset; + + // Collect text before cursor + docContext.beforeCursor = getCursorText(anchorNode, anchorOffset, 'before'); + + // Collect text after cursor + docContext.afterCursor = getCursorText(focusNode, focusOffset, 'after'); + + return docContext; +} + +// DFS traversal to get document order +function collectNodes( + node: ElementNode | LexicalNode, + visitedNodes: Set, + allNodes: LexicalNode[], +) { + const nodeKey = node.getKey(); + if (visitedNodes.has(nodeKey)) return; + visitedNodes.add(nodeKey); + + allNodes.push(node); + + // Add children in document order + if ('getChildren' in node) { + const children = node.getChildren(); + for (const child of children) { + // Recursively collect nodes + collectNodes(child, visitedNodes, allNodes); + } + } +} + +/** + * Gets text from document start to cursor position or from cursor position to document end. + */ +function getCursorText(aNode: LexicalNode, aOffset: number, mode: string): string { + let cursorText = ''; + + const root = $getRoot(); + const visitedNodes = new Set(); + + // Get the text from the current node up to the cursor position + const currentNodeText = aNode.getTextContent(); + + let textInNode = ''; + + if (mode === 'before') { + textInNode = currentNodeText.substring(0, aOffset); + } else if (mode === 'after') { + textInNode = currentNodeText.substring(aOffset); + } + + // First perform a traversal to build document order + const allNodes: LexicalNode[] = []; + const aKey = aNode.getKey(); + + // DFS traversal to get document order + collectNodes(root, visitedNodes, allNodes); + visitedNodes.clear(); + + // Flag to indicate we're past the focus node + let pastFocusNode = false; + + for (const node of allNodes) { + const nodeKey = node.getKey(); + // If we found the anchor node, add partial text and stop + if (nodeKey === aKey) { + cursorText += textInNode; + if (mode === 'before') { + break; + } else if (mode === 'after') { + pastFocusNode = true; + continue; + } + } + + // For other nodes, add appropriate content based on node type + // Only collect text for nodes after the focus + if (pastFocusNode || mode === 'before') { + if (node.getType() === 'text') { + cursorText += node.getTextContent(); + } else if (node.getType() === 'paragraph') { + cursorText += '\r'; + } else if (node.getType() === 'linebreak') { + cursorText += ' '; + } + } + } + + return cursorText; +} + +export default function LexicalEditor({ + updateDocContext, + initialState, + storageKey = 'doc', + preamble, +}: { + updateDocContext: (docContext: DocContext) => void; + initialState: InitialEditorStateType | null; + storageKey?: string; + preamble?: ReactNode; +}) { + return ( + +
+
+ {preamble ?
{preamble}
: null} + + } + placeholder={
} + ErrorBoundary={LexicalErrorBoundary} + /> + + { + editorState.read(() => { + const docContext = $getDocContext(); + + updateDocContext(docContext); + + localStorage.setItem(storageKey, JSON.stringify(editorState)); + const currentDate = new Date().toISOString(); + localStorage.setItem(`${storageKey}-date`, currentDate); + }); + }} + /> + + + + +
+
+ + ); +} diff --git a/frontend/src/editor/styles.module.css b/frontend/components/editor/StandaloneEditor.module.css similarity index 100% rename from frontend/src/editor/styles.module.css rename to frontend/components/editor/StandaloneEditor.module.css diff --git a/frontend/components/editor/StandaloneEditor.tsx b/frontend/components/editor/StandaloneEditor.tsx new file mode 100644 index 00000000..0cc7d054 --- /dev/null +++ b/frontend/components/editor/StandaloneEditor.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useMemo, useRef } from 'react'; +import App from '@/components/App'; +import { EditorContext } from '@/contexts/editorContext'; +import type { DocContext, EditorAPI } from '@/lib/types'; +import LexicalEditor from './LexicalEditor'; +import classes from './StandaloneEditor.module.css'; + +// The standalone surface: a Lexical editor on the left and the sidebar app on the right, +// backed by an in-memory EditorAPI. (The legacy demo mode and Auth0 login flow were +// dropped in the migration.) +export default function StandaloneEditor() { + // Current document context, kept in a ref so the editor API reads the latest value. + const docContextRef = useRef({ + beforeCursor: '', + selectedText: '', + afterCursor: '', + }); + const selectionChangeHandlers = useRef<(() => void)[]>([]); + + const handleSelectionChange = () => { + selectionChangeHandlers.current.forEach((handler) => { + handler(); + }); + }; + + const editorAPI: EditorAPI = useMemo( + () => ({ + getDocContext: async (): Promise => Promise.resolve(docContextRef.current), + addSelectionChangeHandler: (handler: () => void) => { + selectionChangeHandlers.current.push(handler); + }, + removeSelectionChangeHandler: (handler: () => void) => { + const index = selectionChangeHandlers.current.indexOf(handler); + if (index !== -1) selectionChangeHandlers.current.splice(index, 1); + else console.warn('Handler not found'); + }, + selectPhrase: () => { + console.warn('selectPhrase is not implemented yet'); + return Promise.resolve(); + }, + }), + [], + ); + + const docUpdated = (docContext: DocContext) => { + docContextRef.current = docContext; + handleSelectionChange(); + }; + + const getInitialState = () => { + if (typeof window === 'undefined') return undefined; + return localStorage.getItem('doc') || undefined; + }; + + return ( +
+
+ +
+ +
+ + + +
+
+ ); +} diff --git a/frontend/src/editor/editor.module.css b/frontend/components/editor/editor.module.css similarity index 100% rename from frontend/src/editor/editor.module.css rename to frontend/components/editor/editor.module.css diff --git a/frontend/src/pages/chat/styles.module.css b/frontend/components/pages/Chat.module.css similarity index 100% rename from frontend/src/pages/chat/styles.module.css rename to frontend/components/pages/Chat.module.css diff --git a/frontend/components/pages/Chat.tsx b/frontend/components/pages/Chat.tsx new file mode 100644 index 00000000..a280262e --- /dev/null +++ b/frontend/components/pages/Chat.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport, type UIMessage } from 'ai'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { AiOutlineArrowDown, AiOutlineSend } from 'react-icons/ai'; +import { Remark } from 'react-remark'; +import { ChatContext } from '@/contexts/chatContext'; +import { EditorContext } from '@/contexts/editorContext'; +import { useDocContext } from '@/lib/useDocContext'; +import classes from './Chat.module.css'; + +const suggestionPrompts = [ + 'What is my main argument?', + 'How can I improve clarity?', + 'Is my structure logical?', + 'What am I missing?', +]; + +// Extract the plain text from a UI message's parts. +function getMessageText(message: UIMessage): string { + return message.parts + .filter((part) => part.type === 'text') + .map((part) => (part as { text: string }).text) + .join(''); +} + +export default function Chat() { + const editorAPI = useContext(EditorContext); + const docContext = useDocContext(editorAPI); + const { chatMessages, updateChatMessages } = useContext(ChatContext); + + const { messages, sendMessage, status, setMessages } = useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), + }); + + const messagesContainerRef = useRef(null); + const textareaRef = useRef(null); + const [showScrollButton, setShowScrollButton] = useState(false); + const [message, updateMessage] = useState(''); + + const isSendingMessage = status === 'submitted' || status === 'streaming'; + + // Seed from the persisted context once, when this panel mounts empty. + const hasSeededRef = useRef(false); + useEffect(() => { + if (!hasSeededRef.current && messages.length === 0 && chatMessages.length > 0) { + hasSeededRef.current = true; + setMessages(chatMessages); + } + }, [messages.length, chatMessages, setMessages]); + + // Persist meaningful messages back to the context so they survive tab switches. + useEffect(() => { + const meaningful = messages.filter((m) => getMessageText(m).trim() !== ''); + if (meaningful.length > 0) { + updateChatMessages(messages); + } + }, [messages, updateChatMessages]); + + // Show the "scroll to bottom" button when the user scrolls up. + const handleScroll = useCallback(() => { + const container = messagesContainerRef.current; + if (!container) return; + const isNearBottom = + container.scrollHeight - container.scrollTop - container.clientHeight < 100; + setShowScrollButton(!isNearBottom); + }, []); + + const scrollToBottom = useCallback(() => { + const container = messagesContainerRef.current; + if (container) { + container.scrollTop = container.scrollHeight; + } + }, []); + + // Auto-scroll when new messages arrive (unless the user scrolled up). + useEffect(() => { + if (!showScrollButton) { + messagesContainerRef.current?.scrollTo({ + top: messagesContainerRef.current.scrollHeight, + behavior: 'smooth', + }); + } + }, [messages, showScrollButton]); + + const resizeTextarea = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`; + }, []); + + useEffect(() => { + resizeTextarea(); + }, [message, resizeTextarea]); + + const submitMessage = useCallback( + (text: string) => { + setShowScrollButton(false); + updateMessage(''); + // Send the current document context fresh with each turn. + void sendMessage({ text }, { body: { docContext } }); + }, + [sendMessage, docContext], + ); + + function sendCurrentMessage(e: React.FormEvent) { + e.preventDefault(); + const trimmedMessage = message.trim(); + if (!trimmedMessage) return; + submitMessage(trimmedMessage); + } + + const visibleMessages = messages.filter((m) => getMessageText(m).trim() !== ''); + + return ( +
+
+
+ {visibleMessages.length === 0 ? ( +
+
+ What do you think about your document so far? +
+ +
+ {suggestionPrompts.map((prompt) => ( + + ))} +
+
+ ) : ( + visibleMessages.map((chatMessage) => { + const text = getMessageText(chatMessage); + return ( +
+ {chatMessage.role === 'assistant' ? ( +
Assistant
+ ) : null} + +
+ {chatMessage.role === 'assistant' ? {text} : text} +
+ + {chatMessage.role === 'user' ? ( +
You
+ ) : null} +
+ ); + }) + )} +
+ + {showScrollButton ? ( + + ) : null} +
+ +
+
+