Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 32 additions & 16 deletions docs/js-backend-plan.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
# 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.

## Why consider this

- 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.
Expand All @@ -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)
Expand All @@ -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=…' })`
Expand All @@ -57,16 +73,16 @@ Create a sibling app at `backend-next/`:
- Reads `Authorization: Bearer <session_token>`, 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

Expand All @@ -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).
Expand All @@ -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

Expand Down
32 changes: 29 additions & 3 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions frontend/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
19 changes: 19 additions & 0 deletions frontend/app/api/draft/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
19 changes: 19 additions & 0 deletions frontend/app/api/revise/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
47 changes: 47 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
25 changes: 25 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en" className="h-full antialiased">
<body className="min-h-full">
<Provider>
<Providers>{children}</Providers>
</Provider>
</body>
</html>
);
}
13 changes: 13 additions & 0 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div className="p-8 text-sm text-zinc-500">Loading editor…</div>,
});

export default function Home() {
return <StandaloneEditor />;
}
14 changes: 14 additions & 0 deletions frontend/app/taskpane/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-semibold tracking-tight">Thoughtful</h1>
<p className="text-zinc-600">
Task pane scaffold. Office.js integration lands in a subsequent migration
commit.
</p>
</main>
);
}
13 changes: 0 additions & 13 deletions frontend/babel.config.json

This file was deleted.

32 changes: 32 additions & 0 deletions frontend/components/App.tsx
Original file line number Diff line number Diff line change
@@ -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 <Revise />;
case PageName.Chat:
return <Chat />;
case PageName.Draft:
return <Draft />;
}
}

// 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 (
<div className="flex h-full flex-col overflow-hidden">
<Navbar />
<div className="flex flex-1 flex-col overflow-y-auto">{getComponent(page)}</div>
</div>
);
}
Loading
Loading