├── 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 |
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 | [](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);
--------------------------------------------------------------------------------