diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c9ce908 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: + - main + - 'APE-*' + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run lint + run: npm run lint + + - name: Build application + run: npm run build + + deploy: + runs-on: ubuntu-latest + needs: ci + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Deploy to Vercel + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: | + npm install -g vercel@latest + + if [ "${GITHUB_REF_NAME}" = "main" ]; then + PROD_FLAG="--prod" + else + PROD_FLAG="" + fi + + # Let Vercel build remotely (same as Git integration). Local `vercel build` + # typechecks server/ with Node16 rules and fails on extensionless imports. + vercel deploy $PROD_FLAG --token="$VERCEL_TOKEN" diff --git a/.gitignore b/.gitignore index b50664c..d9bb45a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ dist-ssr *.njsproj *.sln *.sw? +.vercel +.env* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index bd231e2..1934e30 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Edit a landing page through conversation with an AI agent. Chat text streams on the left (JSON blocks are hidden in the thread); the preview switches to the proposed page when the reply finishes, then **Apply** commits it for the next turn. **Live demo:** _add your Vercel URL after deploy_ -**Repository:** _add your GitHub URL_ +**Repository:** https://github.com/olegpy/ai-page-editor ## Stack @@ -64,11 +64,33 @@ Production: api/chat.ts → server/chat.ts → OpenAI - `vite.config.ts` middleware does **not** run in production. - Env: local `.env` loaded by `vite/apiDevPlugin.ts`; on Vercel set `OPENAI_API_KEY` in the dashboard. +## CI + +GitHub Actions runs on every push and pull request to `main`: + +- `npm ci` +- `npm run lint` +- `npm run build` + +Workflow: [`.github/workflows/ci.yml`](.github/workflows/ci.yml) + ## Deploy (Vercel) -1. Push to GitHub and import the project in [Vercel](https://vercel.com). -2. Add environment variable: `OPENAI_API_KEY`. -3. Deploy. Routes under `api/` run as serverless functions; the SPA is served from `dist/`. +1. Push `main` to GitHub (CI should pass). +2. [Import the repo](https://vercel.com/new) in Vercel (Git integration). +3. Vercel reads [`vercel.json`](vercel.json): + - **Build:** `npm run build` → output `dist` + - **API:** `api/chat.ts` → serverless `POST /api/chat` + - **SPA:** all non-`/api` routes → `index.html` +4. **Environment variables** (Production + Preview): + + | Name | Value | + |------|--------| + | `OPENAI_API_KEY` | Your OpenAI API key (server only; never `VITE_*`) | + +5. Deploy, then paste the production URL into **Live demo** above. + +`vite/` dev middleware is not used on Vercel — only `api/chat.ts` + the static build. ## Scripts diff --git a/api/chat.ts b/api/chat.ts index e0ee9dd..738cded 100644 --- a/api/chat.ts +++ b/api/chat.ts @@ -1,4 +1,4 @@ -import { CHAT_REQUEST_FAILED, createChatResponse, HttpStatus } from '../server' +import { CHAT_REQUEST_FAILED, createChatResponse, HttpStatus } from '../server/index.js' export async function POST(request: Request) { try { diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..dbba5ab --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true + }, + "include": ["./**/*.ts", "../server/**/*.ts", "../src/landing/contentSchema.ts", "../src/landing/types.ts"] +} diff --git a/server/chat.ts b/server/chat.ts index 37d31e5..01551ad 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -1,13 +1,13 @@ import { createOpenAI } from '@ai-sdk/openai' import { convertToModelMessages, streamText, type UIMessage } from 'ai' -import { getChatPrompt } from './chatPrompt' -import { chatRequestSchema, type ChatRequest } from './chatRequest' -import { HttpStatus } from './httpStatus' +import { getChatPrompt } from './chatPrompt.js' +import { chatRequestSchema, type ChatRequest } from './chatRequest.js' +import { HttpStatus } from './httpStatus.js' import { CHAT_REQUEST_FAILED, INVALID_CHAT_REQUEST, OPENAI_API_KEY_MISSING, -} from './messages' +} from './messages.js' export type { ChatRequest } diff --git a/server/chatPrompt.ts b/server/chatPrompt.ts index 77a7187..da98c49 100644 --- a/server/chatPrompt.ts +++ b/server/chatPrompt.ts @@ -1,4 +1,4 @@ -import type { LandingPageContent } from '../src/landing/types' +import type { LandingPageContent } from '../src/landing/types.js' /** System prompt for POST /api/chat: current page JSON, rules, and expected reply format. */ export function getChatPrompt(pageContent: LandingPageContent): string { diff --git a/server/chatRequest.ts b/server/chatRequest.ts index 896556f..c50c79c 100644 --- a/server/chatRequest.ts +++ b/server/chatRequest.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { landingPageContentSchema } from '../src/landing/contentSchema' +import { landingPageContentSchema } from '../src/landing/contentSchema.js' const textPartSchema = z.object({ type: z.literal('text'), diff --git a/server/index.ts b/server/index.ts index 14822aa..876c27f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,9 +1,9 @@ -export { createChatResponse, type ChatRequest } from './chat' -export { getChatPrompt } from './chatPrompt' -export { chatRequestSchema } from './chatRequest' +export { createChatResponse, type ChatRequest } from './chat.js' +export { getChatPrompt } from './chatPrompt.js' +export { chatRequestSchema } from './chatRequest.js' export { CHAT_REQUEST_FAILED, INVALID_CHAT_REQUEST, OPENAI_API_KEY_MISSING, -} from './messages' -export { HttpStatus, type HttpStatusCode } from './httpStatus' +} from './messages.js' +export { HttpStatus, type HttpStatusCode } from './httpStatus.js' diff --git a/vercel.json b/vercel.json index fc3ac4b..66fa56d 100644 --- a/vercel.json +++ b/vercel.json @@ -1,3 +1,13 @@ { - "rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }] + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "installCommand": "npm ci", + "rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }], + "functions": { + "api/chat.ts": { + "maxDuration": 60 + } + } }