├── static ├── rewrait_logo.png └── rewrait_example.gif ├── client ├── static │ └── favicon.png ├── src │ ├── file-handler.js │ ├── style.css │ ├── editor.js │ ├── api.js │ └── main.js └── index.html ├── .dockerignore ├── .gitignore ├── docker-compose.yml ├── vite.config.js ├── package.json ├── LICENSE ├── Dockerfile ├── server ├── validation.js └── index.js └── README.md /static/rewrait_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALucek/rewrAIt/main/static/rewrait_logo.png -------------------------------------------------------------------------------- /client/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALucek/rewrAIt/main/client/static/favicon.png -------------------------------------------------------------------------------- /static/rewrait_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALucek/rewrAIt/main/static/rewrait_example.gif -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | client/dist 4 | .env 5 | *.log 6 | Dockerfile 7 | docker-compose.yml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Build output 5 | /dist 6 | 7 | # Environment variables 8 | .env 9 | 10 | # Mac 11 | .DS_Store -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: . 4 | security_opt: 5 | - no-new-privileges:true 6 | cap_drop: 7 | - ALL 8 | read_only: true 9 | tmpfs: 10 | - /tmp:exec,nosuid,size=64m 11 | ports: 12 | - "3000:3000" 13 | env_file: 14 | - .env 15 | restart: unless-stopped -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | root: "client", 5 | server: { 6 | proxy: { 7 | "/api": { 8 | target: "http://localhost:3000", 9 | changeOrigin: true, 10 | secure: false, 11 | }, 12 | }, 13 | }, 14 | build: { 15 | outDir: "../dist", 16 | }, 17 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rewrait", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "server": "node server/index.js" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "description": "", 13 | "devDependencies": { 14 | "vite": "^7.0.4" 15 | }, 16 | "dependencies": { 17 | "express": "^4.19.2", 18 | "dotenv": "^16.3.1", 19 | "morgan": "^1.10.0", 20 | "helmet": "^7.0.0", 21 | "cors": "^2.8.5", 22 | "express-rate-limit": "^6.7.0", 23 | "zod": "^3.22.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/file-handler.js: -------------------------------------------------------------------------------- 1 | import { placeCaretAtEnd } from "./editor.js"; 2 | 3 | export function saveSession(text) { 4 | const blob = new Blob([text], { type: "text/plain" }); 5 | const a = document.createElement("a"); 6 | a.download = "rewrait_session.txt"; 7 | a.href = window.URL.createObjectURL(blob); 8 | a.style.display = "none"; 9 | document.body.appendChild(a); 10 | a.click(); 11 | document.body.removeChild(a); 12 | window.URL.revokeObjectURL(a.href); 13 | } 14 | 15 | export function loadSession(editor) { 16 | const input = document.createElement("input"); 17 | input.type = "file"; 18 | input.accept = ".txt,text/plain"; 19 | input.style.display = "none"; 20 | input.onchange = (e) => { 21 | const file = e.target.files[0]; 22 | if (!file) return; 23 | const reader = new FileReader(); 24 | reader.onload = (e) => { 25 | editor.innerText = e.target.result; 26 | placeCaretAtEnd(editor); 27 | }; 28 | reader.readAsText(file); 29 | document.body.removeChild(input); 30 | }; 31 | document.body.appendChild(input); 32 | input.click(); 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Adam Łucek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---------- Build stage ---------- 2 | FROM node:20-alpine AS build 3 | RUN apk update && apk upgrade --no-cache 4 | WORKDIR /app 5 | 6 | # Install dependencies 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | # Copy source files 11 | COPY . . 12 | # Build frontend from client directory via npm script (vite reads root) 13 | RUN npm run build 14 | 15 | # ---------- Production stage ---------- 16 | FROM node:20-alpine 17 | RUN apk update && apk upgrade --no-cache 18 | WORKDIR /app 19 | ENV NODE_ENV=production 20 | 21 | # Copy production artefacts 22 | COPY --from=build /app/dist ./dist 23 | COPY --from=build /app/server ./server 24 | COPY --from=build /app/package*.json ./ 25 | 26 | # Install only production deps 27 | RUN npm install --omit=dev \ 28 | && apk add --no-cache curl \ 29 | # Create non-root user for running the Node app 30 | && addgroup -S appgroup \ 31 | && adduser -S appuser -G appgroup \ 32 | && chown -R appuser:appgroup /app 33 | 34 | # Drop root privileges 35 | USER appuser 36 | 37 | HEALTHCHECK --interval=30s --timeout=5s CMD curl -f http://localhost:3000/health || exit 1 38 | 39 | EXPOSE 3000 40 | 41 | CMD ["node", "server/index.js"] -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rewrAIt 6 | 7 | 8 | 9 | 10 |
11 |

rewrAIt

12 |
13 | 14 | 15 | 16 | 17 | 33 |
34 |
35 |
@system: You are a helpful assistant. 36 | 37 | @user:
38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /server/validation.js: -------------------------------------------------------------------------------- 1 | const { z } = require("zod"); 2 | 3 | // Schema for OpenAI chat completions 4 | const openaiChatSchema = z.object({ 5 | model: z.string().min(1), 6 | stream: z.boolean().optional(), 7 | messages: z 8 | .array( 9 | z.object({ 10 | role: z.string().min(1), 11 | content: z.string().min(1), 12 | }) 13 | ) 14 | .min(1), 15 | }); 16 | 17 | // Schema for Anthropic messages 18 | const anthropicSchema = z.object({ 19 | model: z.string().min(1), 20 | stream: z.boolean().optional(), 21 | max_tokens: z.number().optional(), 22 | system: z.string().optional(), 23 | messages: z 24 | .array( 25 | z.object({ 26 | role: z.string().min(1), 27 | content: z.string().min(1), 28 | }) 29 | ) 30 | .min(1), 31 | }); 32 | 33 | const partSchema = z.object({ 34 | text: z.string().min(1), 35 | }); 36 | 37 | const messageSchema = z.object({ 38 | role: z.enum(["user", "model", "assistant"]).optional(), 39 | parts: z.array(partSchema).min(1), 40 | }); 41 | 42 | const geminiSchema = z.object({ 43 | system_instruction: z 44 | .object({ 45 | parts: z.array(partSchema).min(1), 46 | }) 47 | .optional(), 48 | contents: z.array(messageSchema).min(1), 49 | }); 50 | 51 | function validateBody(schema) { 52 | return (req, res, next) => { 53 | const result = schema.safeParse(req.body); 54 | if (!result.success) { 55 | return res.status(400).json({ error: "Invalid payload" }); 56 | } 57 | req.body = result.data; 58 | next(); 59 | }; 60 | } 61 | 62 | module.exports = { 63 | schemas: { 64 | openaiChatSchema, 65 | anthropicSchema, 66 | geminiSchema, 67 | }, 68 | validateBody, 69 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rewrAIt - Control The Narrative With Your LLM 2 | 3 | 4 | 5 | Are you prompting the model or is the model prompting *you*? 6 | 7 | ## Overview 8 | 9 | rewrAIt offers a unique 'text-editor' style interface for turn-based conversations with large language models that lets you revise **any** part of an LLM conversation- system, user, or AI messages, at any point in time. Add context, modify responses, remove information or change providers to your liking. 10 | 11 | ## Usage Guide 12 | 13 | 14 | 15 | The entire interface is an active text editor with three main labels: `@system:`, `@user:` and `@ai`. Messages are sent to the llm provider when the `enter` key is pressed as the caret is in an active `@user: ` section. AI responses are streamed back with an `@ai: ` label. For newlines in `@user: ` sections use `shift + enter`. 16 | 17 | At any point you can edit any text seen on screen, regardless of whether it's been written by the user or generated by the LLM. Send `@user: clear` to clear the conversation without resetting the system prompt, and use the reset button to fully clear. 18 | 19 | The `save` and `load` buttons both export the conversation into a `.txt` file, or load `.txt` files back into the interface. 20 | 21 | The config button allows you to specify your provider and model of choice. Current providers supported are: `openai`, `anthropic`, and `gemini`. Ensure you have your specific provider key in the environment or `.env` file of your project. 22 | 23 | ## Quick Start 24 | 25 | 1. Clone the repo 26 | 27 | ```bash 28 | $ git clone https://github.com/ALucek/rewrAIt.git 29 | $ cd rewrAIt 30 | ``` 31 | 32 | 2. Create a `.env` file for provider API keys 33 | 34 | ```yaml 35 | OPENAI_API_KEY="" 36 | ANTHROPIC_API_KEY="" 37 | GEMINI_API_KEY="" 38 | ``` 39 | 40 | 3. Deploy with Docker 41 | 42 | ```bash 43 | $ docker compose up --build 44 | ``` 45 | 46 | Once the server is running, open your browser to the printed URL and start your conversation. 47 | 48 | ## Contributing 49 | 50 | Contributions welcome, please feel free to submit a Pull Request. 51 | 52 | Todo: 53 | 1. Local model support 54 | 2. Additional providers (HF, DeepSeek, etc) 55 | 56 | ## License 57 | 58 | MIT License - See [LICENSE](LICENSE) 59 | 60 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 61 | -------------------------------------------------------------------------------- /client/src/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=VT323&display=swap'); 2 | 3 | html { 4 | box-sizing: border-box; 5 | } 6 | *, 7 | *:before, 8 | *:after { 9 | box-sizing: inherit; 10 | } 11 | html, 12 | body { 13 | height: 100%; 14 | margin: 0; 15 | background-color: #000; 16 | color: #0f0; 17 | font-family: 'VT323', monospace; 18 | padding: 5px; 19 | display: flex; 20 | flex-direction: column; 21 | } 22 | 23 | .header-container { 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: center; 27 | margin-bottom: 0.5rem; 28 | padding-left: 10px; 29 | padding-right: 10px; 30 | } 31 | 32 | #title { 33 | margin: 0; 34 | font-weight: normal; 35 | font-size: 1.25rem; 36 | } 37 | 38 | .controls { 39 | margin-bottom: 0; 40 | text-align: right; 41 | } 42 | 43 | .controls button { 44 | background-color: transparent; 45 | border: 2px solid #0f0; 46 | color: #0f0; 47 | font-family: 'VT323', monospace; 48 | padding: 0.25rem 0.75rem; 49 | font-size: 1rem; 50 | cursor: pointer; 51 | border-radius: 5px; 52 | } 53 | 54 | .controls button:hover { 55 | background-color: #0f0; 56 | color: #000; 57 | } 58 | 59 | #toolbar { 60 | display: flex; 61 | gap: 0.5rem; 62 | padding: 0.5rem 0.75rem; 63 | background: #f4f4f4; 64 | border-bottom: 1px solid #d0d0d0; 65 | } 66 | 67 | #editor { 68 | flex-grow: 1; 69 | padding: 1rem; 70 | outline: none; 71 | white-space: pre-wrap; 72 | font-family: 'VT323', monospace; 73 | overflow: auto; 74 | border: 2px solid #0f0; 75 | border-radius: 10px; 76 | background-color: #0d0d0d; 77 | } 78 | 79 | /* Let's make the caret green too */ 80 | #editor:focus { 81 | caret-color: #0f0; 82 | } 83 | 84 | /* zero-width marker that tracks insert position */ 85 | span.marker { 86 | display: inline; 87 | } 88 | 89 | /* --- Config Popup --- */ 90 | .controls { 91 | position: relative; 92 | } 93 | 94 | .popup-container { 95 | position: absolute; 96 | top: calc(100% + 15px); /* Position below the buttons */ 97 | right: 0; 98 | z-index: 100; 99 | width: 300px; 100 | background-color: #0d0d0d; 101 | border: 2px solid #0f0; 102 | border-radius: 8px; 103 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 104 | padding: 16px; 105 | } 106 | 107 | .popup-content h2 { 108 | margin-top: 0; 109 | font-size: 18px; 110 | border-bottom: 1px solid #444; 111 | padding-bottom: 10px; 112 | margin-bottom: 15px; 113 | text-align: center; 114 | } 115 | 116 | .popup-body label { 117 | display: block; 118 | margin-bottom: 8px; 119 | font-size: 14px; 120 | text-align: left; 121 | } 122 | 123 | .popup-body input { 124 | width: calc(100%); 125 | padding: 8px 10px; 126 | margin-bottom: 10px; 127 | border: 1px solid #555; 128 | border-radius: 4px; 129 | background-color: #3a3a3c; 130 | color: #f2f2f7; 131 | } 132 | 133 | .popup-footer { 134 | margin-top: 5px; 135 | text-align: right; 136 | } 137 | 138 | .popup-footer button { 139 | margin-left: 10px; 140 | } -------------------------------------------------------------------------------- /client/src/editor.js: -------------------------------------------------------------------------------- 1 | export function isCursorInUserPrompt(editor) { 2 | /* Check if the caret is currently inside a block of user-written text */ 3 | const sel = window.getSelection(); 4 | if (!sel.rangeCount) return false; 5 | 6 | const range = sel.getRangeAt(0).cloneRange(); 7 | // Create a new range from the start of the editor up to the caret 8 | range.setStart(editor, 0); 9 | 10 | const textBeforeCursor = range.toString(); 11 | const lastUserIndex = textBeforeCursor.lastIndexOf("@user:"); 12 | const lastAiIndex = textBeforeCursor.lastIndexOf("@ai:"); 13 | 14 | // If @user: is present and it's the last role marker we found, we're in a user prompt. 15 | return lastUserIndex !== -1 && lastUserIndex > lastAiIndex; 16 | } 17 | 18 | export function insertMarker() { 19 | const span = document.createElement("span"); 20 | span.className = "marker"; 21 | const sel = window.getSelection(); 22 | sel.getRangeAt(0).insertNode(span); 23 | // Collapse the selection so no text remains highlighted while streaming 24 | sel.removeAllRanges(); 25 | return span; 26 | } 27 | 28 | export function parseConversation(editor) { 29 | /* Parse conversation into [{role,content}] with multi-line messages between markers */ 30 | const text = editor.innerText; 31 | // Match role markers only at the start of a line (optional leading whitespace allowed) 32 | // Capture the role name (system|user|ai) so we can map it directly. 33 | const regex = /^\s*@(?:(system|user|ai)):/gm; 34 | const messages = []; 35 | let match; 36 | let currentRole = null; 37 | let lastIndex = 0; 38 | 39 | while ((match = regex.exec(text)) !== null) { 40 | // Save content collected since the previous marker 41 | if (currentRole) { 42 | const content = text.slice(lastIndex, match.index).trim(); 43 | if (content) messages.push({ role: currentRole, content }); 44 | } 45 | // Update role based on captured group and index for the next iteration 46 | const roleKey = match[1]; 47 | currentRole = roleKey === "ai" ? "assistant" : roleKey; 48 | lastIndex = regex.lastIndex; 49 | } 50 | 51 | // Capture content after the final marker 52 | if (currentRole) { 53 | const content = text.slice(lastIndex).trim(); 54 | if (content) messages.push({ role: currentRole, content }); 55 | } 56 | 57 | return messages; 58 | } 59 | 60 | export function placeCaretAtEnd(el) { 61 | el.focus(); 62 | const sel = window.getSelection(); 63 | const range = document.createRange(); 64 | range.selectNodeContents(el); 65 | range.collapse(false); 66 | sel.removeAllRanges(); 67 | sel.addRange(range); 68 | } 69 | 70 | export function resetEditor(editor) { 71 | const messages = parseConversation(editor); 72 | const systemMessage = messages.find((m) => m.role === "system"); 73 | const systemContent = systemMessage?.content || "You are a helpful assistant."; 74 | 75 | editor.textContent = `@system: ${systemContent}\n\n@user: `; 76 | placeCaretAtEnd(editor); 77 | } 78 | 79 | export function fullResetEditor(editor) { 80 | // Discard any existing system prompt and return to the default 81 | editor.textContent = "@system: You are a helpful assistant.\n\n@user: "; 82 | placeCaretAtEnd(editor); 83 | } -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const helmet = require("helmet"); 3 | const cors = require("cors"); 4 | const rateLimit = require("express-rate-limit"); 5 | const { PassThrough } = require("stream"); 6 | const morgan = require("morgan"); 7 | require("dotenv").config(); 8 | 9 | const app = express(); 10 | // Security middlewares 11 | app.disable("x-powered-by"); 12 | app.use(helmet()); 13 | 14 | // Allow configurable CORS origins via env; fallback to allow all in dev 15 | const allowedOrigins = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()); 16 | app.use( 17 | cors({ 18 | origin: allowedOrigins && allowedOrigins.length ? allowedOrigins : true, 19 | optionsSuccessStatus: 200, 20 | }) 21 | ); 22 | 23 | // Basic rate limiting – 60 requests per minute per IP on API routes 24 | const limiter = rateLimit({ windowMs: 60 * 1000, max: 60 }); 25 | app.use("/api", limiter); 26 | app.use(express.json({ limit: "2mb" })); 27 | // HTTP request logging 28 | app.use(morgan("combined")); 29 | 30 | // Heartbeat endpoint 31 | app.get("/health", (req, res) => res.status(200).json({ status: "ok" })); 32 | 33 | // --- Static file serving for production build --- 34 | const path = require("path"); 35 | const distDir = path.join(__dirname, "../dist"); 36 | app.use(express.static(distDir)); 37 | 38 | // SPA fallback: serve index.html for any unknown GET route 39 | app.get("/*", (req, res, next) => { 40 | if (req.method !== "GET" || req.path.startsWith("/api")) return next(); 41 | res.sendFile(path.join(distDir, "index.html")); 42 | }); 43 | 44 | const OPENAI_URL = "https://api.openai.com/v1/chat/completions"; 45 | const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"; 46 | 47 | const { validateBody, schemas } = require("./validation.js"); 48 | 49 | app.post( 50 | "/api/openai/chat/completions", 51 | validateBody(schemas.openaiChatSchema), 52 | async (req, res) => { 53 | try { 54 | const upstream = await fetch(OPENAI_URL, { 55 | method: "POST", 56 | headers: { 57 | "Content-Type": "application/json", 58 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 59 | }, 60 | body: JSON.stringify(req.body), 61 | }); 62 | 63 | if (!upstream.ok) { 64 | const text = await upstream.text(); 65 | res.status(upstream.status).end(text); 66 | return; 67 | } 68 | 69 | res.setHeader("Content-Type", "text/event-stream"); 70 | res.setHeader("Cache-Control", "no-cache"); 71 | res.setHeader("Connection", "keep-alive"); 72 | 73 | for await (const chunk of upstream.body) { 74 | res.write(chunk); 75 | } 76 | res.end(); 77 | } catch (err) { 78 | console.error("OpenAI proxy error", err); 79 | res.status(500).json({ error: "Proxy error" }); 80 | } 81 | } 82 | ); 83 | 84 | app.post( 85 | "/api/anthropic/messages", 86 | validateBody(schemas.anthropicSchema), 87 | async (req, res) => { 88 | try { 89 | const upstream = await fetch(ANTHROPIC_URL, { 90 | method: "POST", 91 | headers: { 92 | "Content-Type": "application/json", 93 | "x-api-key": process.env.ANTHROPIC_API_KEY, 94 | "anthropic-version": "2023-06-01", 95 | }, 96 | body: JSON.stringify(req.body), 97 | }); 98 | 99 | if (!upstream.ok) { 100 | const text = await upstream.text(); 101 | res.status(upstream.status).end(text); 102 | return; 103 | } 104 | 105 | res.setHeader("Content-Type", "text/event-stream"); 106 | res.setHeader("Cache-Control", "no-cache"); 107 | res.setHeader("Connection", "keep-alive"); 108 | 109 | for await (const chunk of upstream.body) { 110 | res.write(chunk); 111 | } 112 | res.end(); 113 | } catch (err) { 114 | console.error("Anthropic proxy error", err); 115 | res.status(500).json({ error: "Proxy error" }); 116 | } 117 | } 118 | ); 119 | 120 | app.post( 121 | "/api/gemini/:model", 122 | validateBody(schemas.geminiSchema), 123 | async (req, res) => { 124 | const { model } = req.params; 125 | const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`; 126 | try { 127 | const upstream = await fetch(url, { 128 | method: "POST", 129 | headers: { 130 | "Content-Type": "application/json", 131 | "x-goog-api-key": process.env.GEMINI_API_KEY, 132 | }, 133 | body: JSON.stringify(req.body), 134 | }); 135 | 136 | if (!upstream.ok) { 137 | const text = await upstream.text(); 138 | res.status(upstream.status).end(text); 139 | return; 140 | } 141 | 142 | res.setHeader("Content-Type", "text/event-stream"); 143 | res.setHeader("Cache-Control", "no-cache"); 144 | res.setHeader("Connection", "keep-alive"); 145 | 146 | for await (const chunk of upstream.body) { 147 | res.write(chunk); 148 | } 149 | res.end(); 150 | } catch (err) { 151 | console.error("Gemini proxy error", err); 152 | res.status(500).json({ error: "Proxy error" }); 153 | } 154 | } 155 | ); 156 | 157 | const PORT = process.env.PORT || 3000; 158 | app.listen(PORT, () => console.log(`Proxy server listening on port ${PORT}`)); -------------------------------------------------------------------------------- /client/src/api.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_MODEL = "gpt-4o-mini"; 2 | // All API calls are proxied through the backend at /api to keep keys server-side 3 | const API_BASE = "/api"; 4 | 5 | // Inactivity timeout so stalled network connections don't hang forever 6 | const STREAM_TIMEOUT_MS = 30000; // 30 seconds per chunk 7 | async function readWithTimeout(reader, timeout = STREAM_TIMEOUT_MS) { 8 | let timeoutId; 9 | try { 10 | return await Promise.race([ 11 | reader.read(), 12 | new Promise((_, reject) => { 13 | timeoutId = setTimeout(() => reject(new Error("Stream timeout")), timeout); 14 | }), 15 | ]); 16 | } finally { 17 | clearTimeout(timeoutId); 18 | } 19 | } 20 | 21 | /* ---- Fetch wrapper that yields tokens as they arrive ----*/ 22 | export async function* streamCompletion(messages, signal, model) { 23 | const modelName = model || DEFAULT_MODEL; 24 | const res = await fetch(`${API_BASE}/openai/chat/completions`, { 25 | method: "POST", 26 | headers: { 27 | "Content-Type": "application/json", 28 | }, 29 | body: JSON.stringify({ model: modelName, stream: true, messages }), 30 | signal, 31 | }); 32 | 33 | // Surface HTTP errors immediately 34 | if (!res.ok) { 35 | const errorText = await res.text(); 36 | throw new Error(`OpenAI API error: ${res.status} ${errorText}`); 37 | } 38 | 39 | const reader = res.body.getReader(); 40 | const decoder = new TextDecoder(); 41 | let buffer = ""; 42 | 43 | try { 44 | while (true) { 45 | const { value, done } = await readWithTimeout(reader); 46 | if (done) break; 47 | 48 | buffer += decoder.decode(value, { stream: true }); 49 | const lines = buffer.split(/\r?\n/); 50 | buffer = lines.pop(); // keep incomplete line for next read 51 | 52 | for (const line of lines) { 53 | if (!line.startsWith("data:")) continue; 54 | const payload = line.replace("data:", "").trim(); 55 | if (payload === "[DONE]") return; 56 | try { 57 | const json = JSON.parse(payload); 58 | const token = json.choices?.[0]?.delta?.content; 59 | if (token) yield token; 60 | } catch (_) { 61 | /* skip malformed line */ 62 | } 63 | } 64 | } 65 | } catch (err) { 66 | try { 67 | reader.cancel(); 68 | } catch (_) { 69 | /* swallow */ 70 | } 71 | throw err; 72 | } 73 | } 74 | 75 | export async function* streamAnthropicCompletion(messages, signal, model, system) { 76 | const res = await fetch(`${API_BASE}/anthropic/messages`, { 77 | method: "POST", 78 | headers: { 79 | "Content-Type": "application/json", 80 | "anthropic-version": "2023-06-01", 81 | }, 82 | body: JSON.stringify({ 83 | model, 84 | system, 85 | messages, 86 | stream: true, 87 | max_tokens: 4096, 88 | }), 89 | signal, 90 | }); 91 | 92 | if (!res.ok) { 93 | const errorText = await res.text(); 94 | throw new Error(`Anthropic API error: ${res.status} ${errorText}`); 95 | } 96 | 97 | const reader = res.body.getReader(); 98 | const decoder = new TextDecoder(); 99 | let buffer = ""; 100 | 101 | try { 102 | while (true) { 103 | const { value, done } = await readWithTimeout(reader); 104 | if (done) break; 105 | 106 | buffer += decoder.decode(value, { stream: true }); 107 | const lines = buffer.split(/\r?\n/); 108 | buffer = lines.pop(); // keep incomplete line for next read 109 | 110 | for (const line of lines) { 111 | if (!line.startsWith("data:")) continue; 112 | const payload = line.replace("data:", "").trim(); 113 | if (!payload) continue; 114 | try { 115 | const json = JSON.parse(payload); 116 | if (json.type === "content_block_delta") { 117 | const token = json.delta?.text; 118 | if (token) yield token; 119 | } else if (json.type === "error") { 120 | console.error(`Anthropic API error: ${json.error.message}`); 121 | yield `[ERROR: ${json.error.message}]`; 122 | return; 123 | } 124 | } catch (_) { 125 | /* skip malformed line */ 126 | } 127 | } 128 | } 129 | } catch (err) { 130 | try { 131 | reader.cancel(); 132 | } catch (_) { 133 | /* swallow */ 134 | } 135 | throw err; 136 | } 137 | } 138 | 139 | /* ---- Fetch wrapper that yields tokens as they arrive ----*/ 140 | export async function* streamGeminiCompletion(messages, signal, model) { 141 | // Gemini expects separate system instruction and contents array 142 | const systemMessage = messages.find((m) => m.role === "system"); 143 | const system_instruction = systemMessage 144 | ? { 145 | system_instruction: { 146 | parts: [{ text: systemMessage.content }], 147 | }, 148 | } 149 | : {}; 150 | 151 | // Convert conversation into Gemini "contents" format 152 | const contents = messages 153 | .filter((m) => m.role !== "system") 154 | .map((m) => ({ 155 | role: m.role === "assistant" || m.role === "ai" ? "model" : "user", 156 | parts: [{ text: m.content }], 157 | })); 158 | 159 | const body = JSON.stringify({ ...system_instruction, contents }); 160 | const endpoint = `${API_BASE}/gemini/${model}`; 161 | 162 | const res = await fetch(endpoint, { 163 | method: "POST", 164 | headers: { 165 | "Content-Type": "application/json", 166 | }, 167 | body, 168 | signal, 169 | }); 170 | 171 | if (!res.ok) { 172 | const errorText = await res.text(); 173 | throw new Error(`Gemini API error: ${res.status} ${errorText}`); 174 | } 175 | 176 | const reader = res.body.getReader(); 177 | const decoder = new TextDecoder(); 178 | let buffer = ""; 179 | 180 | try { 181 | while (true) { 182 | const { value, done } = await readWithTimeout(reader); 183 | if (done) break; 184 | buffer += decoder.decode(value, { stream: true }); 185 | const lines = buffer.split(/\r?\n/); 186 | buffer = lines.pop(); 187 | 188 | for (const line of lines) { 189 | if (!line.startsWith("data:")) continue; 190 | const payload = line.replace("data:", "").trim(); 191 | if (payload === "[DONE]") return; 192 | if (!payload) continue; 193 | try { 194 | const json = JSON.parse(payload); 195 | const token = json.candidates?.[0]?.content?.parts?.[0]?.text; 196 | if (token) yield token; 197 | } catch (_) { 198 | /* ignore malformed */ 199 | } 200 | } 201 | } 202 | } catch (err) { 203 | try { 204 | reader.cancel(); 205 | } catch (_) { 206 | /* swallow */ 207 | } 208 | throw err; 209 | } 210 | } -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import { streamCompletion, streamAnthropicCompletion, streamGeminiCompletion } from "./api.js"; 2 | import { 3 | isCursorInUserPrompt, 4 | insertMarker, 5 | parseConversation, 6 | placeCaretAtEnd, 7 | resetEditor as softResetEditor, 8 | fullResetEditor, 9 | } from "./editor.js"; 10 | import { saveSession, loadSession } from "./file-handler.js"; 11 | 12 | const editor = document.getElementById("editor"); 13 | const saveBtn = document.getElementById("save-btn"); 14 | const loadBtn = document.getElementById("load-btn"); 15 | const configBtn = document.getElementById("config-btn"); 16 | const resetBtn = document.getElementById("reset-btn"); 17 | const configPopup = document.getElementById("config-popup"); 18 | const popupCancelBtn = document.getElementById("popup-cancel-btn"); 19 | const popupSaveBtn = document.getElementById("popup-save-btn"); 20 | const modelInput = document.getElementById("model-input"); 21 | const providerInput = document.getElementById("provider-input"); 22 | 23 | const MODEL_STORAGE_KEY = "rewrait-llm-model"; 24 | const DEFAULT_MODEL = "gpt-4o-mini"; 25 | const PROVIDER_STORAGE_KEY = "rewrait-llm-provider"; 26 | const DEFAULT_PROVIDER = "openai"; 27 | const ALLOWED_PROVIDERS = ["openai", "anthropic", "google", "gemini"]; 28 | 29 | let currentAbortController = null; 30 | 31 | function getModel() { 32 | const stored = localStorage.getItem(MODEL_STORAGE_KEY); 33 | return stored && stored.trim() ? stored.trim() : DEFAULT_MODEL; 34 | } 35 | 36 | function getProvider() { 37 | const stored = (localStorage.getItem(PROVIDER_STORAGE_KEY) || "").toLowerCase(); 38 | return ALLOWED_PROVIDERS.includes(stored) ? stored : DEFAULT_PROVIDER; 39 | } 40 | 41 | // Full reset – discard custom system prompt 42 | function resetEditor() { 43 | if (currentAbortController) { 44 | currentAbortController.abort(); 45 | currentAbortController = null; 46 | } 47 | fullResetEditor(editor); 48 | } 49 | 50 | // Soft reset via typing "clear" – keep current system prompt 51 | function softReset() { 52 | if (currentAbortController) { 53 | currentAbortController.abort(); 54 | currentAbortController = null; 55 | } 56 | softResetEditor(editor); 57 | } 58 | 59 | editor.addEventListener("keydown", async (e) => { 60 | if (e.key !== "Enter") return; 61 | 62 | // Only trigger if the cursor is inside a user prompt block 63 | if (!isCursorInUserPrompt(editor)) return; 64 | 65 | // In a user prompt block, Shift+Enter should add a newline. 66 | if (e.shiftKey) { 67 | return; // Allow default newline behavior 68 | } 69 | 70 | e.preventDefault(); // stop default newline for cleaner UX 71 | await runQuery(); 72 | }); 73 | 74 | saveBtn.addEventListener("click", () => { 75 | saveSession(editor.innerText); 76 | }); 77 | 78 | loadBtn.addEventListener("click", () => { 79 | loadSession(editor); 80 | }); 81 | 82 | resetBtn.addEventListener("click", () => { 83 | resetEditor(); 84 | }); 85 | 86 | configBtn.addEventListener("click", (e) => { 87 | e.stopPropagation(); // prevent this click from closing the popup immediately 88 | const isVisible = configPopup.style.display === "block"; 89 | if (isVisible) { 90 | hidePopup(); 91 | } else { 92 | modelInput.value = getModel(); 93 | providerInput.value = getProvider(); 94 | configPopup.style.display = "block"; 95 | document.addEventListener("click", closePopupOnOutsideClick); 96 | } 97 | }); 98 | 99 | function hidePopup() { 100 | configPopup.style.display = "none"; 101 | document.removeEventListener("click", closePopupOnOutsideClick); 102 | } 103 | 104 | function closePopupOnOutsideClick(e) { 105 | if (!configPopup.contains(e.target)) { 106 | hidePopup(); 107 | } 108 | } 109 | 110 | popupCancelBtn.addEventListener("click", hidePopup); 111 | 112 | popupSaveBtn.addEventListener("click", () => { 113 | const newModel = modelInput.value.trim(); 114 | if (newModel) { 115 | localStorage.setItem(MODEL_STORAGE_KEY, newModel); 116 | } 117 | 118 | const newProvider = providerInput.value.trim().toLowerCase(); 119 | if (ALLOWED_PROVIDERS.includes(newProvider)) { 120 | localStorage.setItem(PROVIDER_STORAGE_KEY, newProvider); 121 | } else if (newProvider) { 122 | alert(`Unsupported provider: ${newProvider}. \n\nValid options: ${ALLOWED_PROVIDERS.join(", ")}`); 123 | } 124 | hidePopup(); 125 | }); 126 | 127 | async function runQuery() { 128 | // 1-- build messages array from the whole doc (user prompt is already in the editor) 129 | const messages = parseConversation(editor); 130 | 131 | const lastMessage = messages.at(-1); 132 | if (lastMessage?.role === "user" && lastMessage?.content.trim().toLowerCase() === "clear") { 133 | softReset(); 134 | return; 135 | } 136 | 137 | // 2-- cancel any previous in-flight stream 138 | if (currentAbortController) { 139 | currentAbortController.abort(); 140 | } 141 | const abortCtrl = new AbortController(); 142 | currentAbortController = abortCtrl; 143 | 144 | // 3-- place a zero-width marker at caret and prepend the AI label on a new line 145 | const marker = insertMarker(); 146 | marker.insertAdjacentText("beforebegin", "\n\n@ai: "); 147 | 148 | // 4-- stream tokens and insert them before marker 149 | try { 150 | const model = getModel(); 151 | const provider = getProvider().toLowerCase(); 152 | 153 | if (provider === "anthropic") { 154 | const systemMessage = messages.find((m) => m.role === "system"); 155 | const system = systemMessage?.content; 156 | const otherMessages = messages.filter((m) => m.role !== "system"); 157 | 158 | for await (const token of streamAnthropicCompletion( 159 | otherMessages, 160 | abortCtrl.signal, 161 | model, 162 | system 163 | )) { 164 | marker.insertAdjacentText("beforebegin", token); 165 | marker.scrollIntoView({ block: "nearest" }); // minimal autoscroll 166 | } 167 | } else if (provider === "google" || provider === "gemini") { 168 | for await (const token of streamGeminiCompletion( 169 | messages, 170 | abortCtrl.signal, 171 | model 172 | )) { 173 | marker.insertAdjacentText("beforebegin", token); 174 | marker.scrollIntoView({ block: "nearest" }); 175 | } 176 | } else { 177 | for await (const token of streamCompletion(messages, abortCtrl.signal, model)) { 178 | marker.insertAdjacentText("beforebegin", token); 179 | marker.scrollIntoView({ block: "nearest" }); // minimal autoscroll 180 | } 181 | } 182 | } catch (err) { 183 | if (err.name !== "AbortError") { 184 | console.error(err); 185 | // Show the error inline 186 | marker.insertAdjacentText("beforebegin", `[ERROR: ${err.message}]`); 187 | marker.scrollIntoView({ block: "nearest" }); 188 | } 189 | } finally { 190 | marker.remove(); 191 | currentAbortController = null; 192 | 193 | // After AI response, normalise trailing whitespace so we don't accumulate extra blank lines 194 | // Remove any spaces or newlines at the very end of the document 195 | editor.textContent = editor.textContent.replace(/\s+$/u, ""); 196 | 197 | // Create a fresh prompt line for the user with exactly two leading newlines 198 | editor.append("\n\n@user: "); 199 | placeCaretAtEnd(editor); 200 | } 201 | } 202 | 203 | // Initialize editor with a default system prompt 204 | softResetEditor(editor); --------------------------------------------------------------------------------