Compare commits

...

9 Commits

Author SHA1 Message Date
5f9c8b7551 Add corporate and senator fun styles
Introduce two new fun styles “Corporate Bullshit” and
“Senator John Kennedy” with corresponding prompt modifiers.

Update tests to cover the new styles and verify the fun category
now contains seven entries.

Add AGENTS.md documenting repository agents
and update package-lock with optional @emnapi core and runtime dev
dependencies.
2026-04-15 19:12:28 -04:00
204edafa40 feat: add Tharoorian English style under Fun category
Imitation of Shashi Tharoor's distinctive oratory — sesquipedalian
vocabulary, serpentine sentences, literary allusions, Oxonian wit,
arcane word choices, rhetorical flourish, em-dash asides, and the
unwavering commitment to never use a short word when a magnificently
polysyllabic one will do.
2026-04-13 10:05:30 -04:00
4a783f28a1 feat: add Caveman style under Fun category
Prompt: rewrite as a neanderthal caveman — broken grammar,
grunt words (ugh, oog), simplest names (big rock, fire stick),
dropped articles and conjugations, thoughts about food/shelter/danger,
raw emotional outbursts.
2026-04-13 01:30:19 -04:00
c96d97e154 feat: add Umami analytics with tagged events
Add Umami web analytics tracking script to the layout head, and
instrument all interactive elements with event tracking:

Declarative (data-umami-event attributes):
- Category selector: select_category
- Style selector: select_style
- Intensity slider: adjust_intensity
- Prompt toggle: toggle_prompt (with open/close action)

Programmatic (umami.track with metadata):
- convert_click: { style, intensity }
- convert_success: { style, intensity, model }
- convert_error: { style, intensity, error }
- copy_result: { style }
2026-04-13 01:21:20 -04:00
eaa1544e66 feat: show routed model name when using openrouter/free
When the configured model is a routing endpoint like 'openrouter/free',
the actual model used (e.g. 'upstage/solar-pro-3:free') is returned in
the LLM response's 'model' field. We now extract that and display it as:

  Responded by upstage/solar-pro-3:free model from openrouter/free

For any other model (e.g. 'llama3', 'gemma2'), we still show just:

  Responded by llama3

Implementation:
- LLM client returns both requestedModel and actualModel
- API endpoint builds a display-friendly modelLabel
- Frontend uses modelLabel for the attribution line
2026-04-13 01:13:47 -04:00
70dc396fe3 fix: remove hardcoded secrets from docker-compose, use env vars + profiles
- Remove hardcoded OpenRouter API key and URL from docker-compose.yml
- App service now reads OPENAI_* vars from .env file (env_file) and
  falls back to http://ollama:11434/v1 defaults
- Ollama and model-init moved to 'ollama' Docker Compose profile,
  so they only start when explicitly requested:
    docker compose --profile ollama up      # with local Ollama
    docker compose up                         # cloud provider only
- Port mapping uses 5656 from .env
- .env.docker updated with documented options for Ollama vs OpenRouter
2026-04-13 01:06:23 -04:00
44f024f1d5 feat: add AI disclaimer banner and persist UI state in localStorage
1. Disclaimer: amber-colored banner between the intensity slider and
   the Convert button warning users that:
   - Results are AI-generated and may be inaccurate or biased
   - Do not enter personal or sensitive information
   - Use at your own discretion, demo only

2. State persistence: all UI state is saved to localStorage under
   'english-styler-state' and restored on page load:
   - Input text
   - Selected category and style
   - Intensity slider position
   - Accordion (Show prompt) open/close state
   Uses () to auto-save whenever state changes.
2026-04-13 00:53:54 -04:00
86d399a04b fix: use npm i instead of npm ci in Docker build, expose on port 5656
- Dockerfile: drop package-lock.json copy, use npm i instead of npm ci
  so install works even if lock file is slightly out of sync
- docker-compose: map host port 5656 → container port 3000
2026-04-13 00:43:19 -04:00
ca583ea6c9 fix: Dockerfile uses npm prune instead of npm ci for production deps
The run stage no longer runs npm ci --omit=dev (which fails when
package-lock.json and the Docker environment's resolved deps are
out of sync, e.g. @emnapi/* transitive deps from adapter-node).

Instead, the build stage runs npm prune --omit=dev after building,
and the run stage copies the already-pruned node_modules. This
avoids any lock file sync issues across different environments.
2026-04-13 00:36:12 -04:00
13 changed files with 622 additions and 442 deletions

View File

@@ -1,3 +1,21 @@
# Model to use with Ollama — change this to any Ollama-compatible model # ──────────────────────────────────────────────
# Examples: llama3, llama3.1, llama3.2, gemma2, mistral, phi3, qwen2, codellama # English Style Converter — Docker Configuration
OLLAMA_MODEL=llama3 # ──────────────────────────────────────────────
#
# Copy this file to .env and uncomment the profile you want.
# Or just set the three OPENAI_* vars for any provider.
# ── Option A: Ollama (local, free) ────────────
# Run: docker compose --profile ollama up
# OPENAI_BASE_URL=http://ollama:11434/v1
# OPENAI_API_KEY=ollama
# OLLAMA_MODEL=llama3
# OPENAI_MODEL=${OLLAMA_MODEL}
# ── Option B: OpenRouter (cloud, free tier) ───
OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_API_KEY=sk-or-v1-YOUR_KEY_HERE
OPENAI_MODEL=openrouter/free
# ── Port mapping ──────────────────────────────
APP_PORT=5656

27
AGENTS.md Normal file
View File

@@ -0,0 +1,27 @@
# AGENTS.md
Short map. Read only what task needs.
## Core
- App: SvelteKit + TypeScript + Vitest.
- Keep changes small. Match existing style. No extra deps unless needed.
- Prefer repo docs over guessing.
## Read on demand
- Product / setup: `README.md`
- Design: `docs/superpowers/specs/2025-04-12-english-style-converter-design.md`
- Build plan: `docs/superpowers/plans/2025-04-12-english-style-converter.md`
- Style data: `src/lib/styles.ts`
- LLM + prompts: `src/lib/llm.ts`
- API: `src/routes/api/convert/+server.ts`
- UI: `src/routes/+page.svelte`
- Shared types: `src/lib/types.ts`
- Component UI bits: `src/lib/components/*`
## Tests
- Use existing `npm test` / `npm run check`.
- Add or update tests near touched code.
## Notes
- Server-only secrets stay in env vars.
- Do not leak private prompt / defense details into UI.

View File

@@ -3,25 +3,26 @@ FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json ./
RUN npm ci RUN npm i
COPY . . COPY . .
RUN npm run build RUN npm run build
# Prune dev dependencies for the production image
RUN npm prune --omit=dev
# ---- Run stage ---- # ---- Run stage ----
FROM node:22-alpine AS run FROM node:22-alpine AS run
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY --from=build /app/package.json ./
RUN npm ci --omit=dev COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
COPY --from=build /app/package.json .
ENV PORT=3000 ENV PORT=3000
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
EXPOSE 3000 EXPOSE 3000
CMD ["node", "build"] CMD ["node", "build"]

View File

@@ -1,6 +1,8 @@
services: services:
ollama: ollama:
image: ollama/ollama:latest image: ollama/ollama:latest
profiles:
- ollama
container_name: english-styler-ollama container_name: english-styler-ollama
ports: ports:
- "11434:11434" - "11434:11434"
@@ -16,6 +18,8 @@ services:
model-init: model-init:
image: ollama/ollama:latest image: ollama/ollama:latest
profiles:
- ollama
container_name: english-styler-model-init container_name: english-styler-model-init
depends_on: depends_on:
ollama: ollama:
@@ -34,14 +38,14 @@ services:
build: . build: .
container_name: english-styler-app container_name: english-styler-app
ports: ports:
- "3000:3000" - "${APP_PORT:-5656}:3000"
depends_on: env_file:
model-init: - path: .env
condition: service_completed_successfully required: false
environment: environment:
OPENAI_BASE_URL: http://ollama:11434/v1 OPENAI_BASE_URL: ${OPENAI_BASE_URL:-http://ollama:11434/v1}
OPENAI_API_KEY: ollama OPENAI_API_KEY: ${OPENAI_API_KEY:-ollama}
OPENAI_MODEL: ${OLLAMA_MODEL:-llama3} OPENAI_MODEL: ${OPENAI_MODEL:-llama3}
restart: unless-stopped restart: unless-stopped
volumes: volumes:

30
package-lock.json generated
View File

@@ -19,6 +19,29 @@
"vitest": "^4.1.3" "vitest": "^4.1.3"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -899,7 +922,6 @@
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==", "integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
@@ -942,7 +964,6 @@
"integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -1133,7 +1154,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1886,7 +1906,6 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -2011,7 +2030,6 @@
"integrity": "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==", "integrity": "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2126,7 +2144,6 @@
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2141,7 +2158,6 @@
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",

5
src/app.d.ts vendored
View File

@@ -8,6 +8,11 @@ declare global {
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
// Umami analytics tracker (injected by script tag)
const umami: {
track(event: string, data?: Record<string, string | number | boolean>): void;
};
} }
export {}; export {};

View File

@@ -21,7 +21,8 @@ export interface ConvertResult {
converted: string; converted: string;
publicSystemPrompt: string; publicSystemPrompt: string;
publicUserMessage: string; publicUserMessage: string;
model: string; requestedModel: string;
actualModel: string | null;
} }
const INPUT_TAG_START = '###### USER INPUT START ######'; const INPUT_TAG_START = '###### USER INPUT START ######';
@@ -100,10 +101,15 @@ export async function convertText(
throw new Error('LLM returned empty response'); throw new Error('LLM returned empty response');
} }
// OpenRouter's free router returns the actual model used in data.model
// e.g. requested "openrouter/free" -> actual "upstage/solar-pro-3:free"
const actualModel = (typeof data.model === 'string' && data.model !== merged.model) ? data.model : null;
return { return {
converted, converted,
publicSystemPrompt: buildPublicSystemPrompt(styleModifier, intensityInstruction), publicSystemPrompt: buildPublicSystemPrompt(styleModifier, intensityInstruction),
publicUserMessage: text, publicUserMessage: text,
model: merged.model requestedModel: merged.model,
actualModel
}; };
} }

View File

@@ -57,6 +57,11 @@ describe('getStylesByCategory', () => {
const result = getStylesByCategory('general'); const result = getStylesByCategory('general');
expect(result.length).toBe(6); expect(result.length).toBe(6);
}); });
it('fun category has 7 styles', () => {
const result = getStylesByCategory('fun');
expect(result.length).toBe(7);
});
}); });
describe('getStyleById', () => { describe('getStyleById', () => {
@@ -70,6 +75,11 @@ describe('getStyleById', () => {
const result = getStyleById('nonexistent'); const result = getStyleById('nonexistent');
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('returns new fun styles by id', () => {
expect(getStyleById('corporate-bullshit')).toBeDefined();
expect(getStyleById('senator-john-kennedy')).toBeDefined();
});
}); });
describe('getCategoryById', () => { describe('getCategoryById', () => {
@@ -109,4 +119,4 @@ describe('getIntensityConfig', () => {
expect(config!.instruction).not.toContain('{style}'); expect(config!.instruction).not.toContain('{style}');
} }
}); });
}); });

View File

@@ -157,6 +157,34 @@ export const styles: Style[] = [
promptModifier: promptModifier:
"Rewrite in Gen Z slang with no cap, fr, and modern internet vernacular", "Rewrite in Gen Z slang with no cap, fr, and modern internet vernacular",
}, },
{
id: "corporate-bullshit",
label: "Corporate Bullshit",
categoryId: "fun",
promptModifier:
"Rewrite in polished corporate-speak loaded with buzzwords, euphemisms, and strategic vagueness. Use phrases like circle back, align on priorities, move the needle, best-in-class, leverage synergies, and stakeholder alignment. Prefer passive voice, meeting-room tone, and language that sounds important while saying as little as possible. Keep it coherent enough to pass as a real update, but inflate every plain statement into consultant-grade business jargon.",
},
{
id: "caveman",
label: "Caveman",
categoryId: "fun",
promptModifier:
"Rewrite as a neanderthal caveman would speak — use broken grammar, grunt words like ugh and oog, refer to things by their simplest names like big rock and fire stick, shorten every word, drop articles and verb conjugations entirely, think only about food, shelter, and danger, and express emotions through raw outbursts",
},
{
id: "tharoorian",
label: "Tharoorian English",
categoryId: "fun",
promptModifier:
"Rewrite in the unmistakable style of Shashi Tharoor — deploy sesquipedalian vocabulary with unapologetic ostentation, construct serpentine sentences brimming with subordinate clauses and appositives that cascade toward a devastatingly witty payoff, weave in literary allusions and historical references with the casual air of a man who reads dictionaries for recreation, employ dry Oxonian wit that lands with the precision of a surgeon's scalpel wrapped in a velvet epigram, favour the arcane over the accessible (perspicacious over smart, obfuscate over hide, floccinaucinihilipilification over dismissal), indulge in alliteration and rhetorical flourish, frame even the mundane as though addressing a hushed auditorium, punctuate with sardonic asides set off by em dashes, and never use a short word when a magnificently polysyllabic one will send the listener reaching for their Oxford dictionary",
},
{
id: "senator-john-kennedy",
label: "Senator John Kennedy",
categoryId: "fun",
promptModifier:
"Rewrite as Senator John Kennedy of Louisiana. Voice: plainspoken, folksy, sharp, deadpan, and funny without sounding polished. Make the text sound like a witty Senate-floor quip or a TV interview zinger from a man who translates Washington nonsense into common-sense language. Use short punchy sentences, vivid Southern metaphors, comic comparisons, dry ridicule, and blunt moral judgments. Favor everyday imagery like steak, gumbo, crawfish, pickup trucks, church, a money tree, or something 'tougher than' whatever is being discussed. Lean into contrast between common people and Washington insiders. Keep the jokes clean, the timing crisp, and the tone slightly grumpy but good-natured. The result should feel quotable, homespun, and slyly insulting in a clever way. Do not copy exact famous lines; only evoke the style.",
},
// Game of Thrones // Game of Thrones
{ {

View File

@@ -24,7 +24,7 @@ export interface ConversionResponse {
intensity: number; intensity: number;
systemPrompt: string; systemPrompt: string;
userMessage: string; userMessage: string;
model: string; modelLabel: string;
} }
export interface LLMConfig { export interface LLMConfig {

View File

@@ -6,6 +6,7 @@
<svelte:head> <svelte:head>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>✨</text></svg>" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>✨</text></svg>" />
<script defer src="https://wa.santhoshj.com/script.js" data-website-id="1004961a-2b1f-4c45-95a6-78059acac472"></script>
</svelte:head> </svelte:head>
{@render children()} {@render children()}

View File

@@ -1,478 +1,540 @@
<script lang="ts"> <script lang="ts">
import { import { categories, getStylesByCategory, getIntensityConfig } from '$lib/styles';
categories, import type { ConversionResponse } from '$lib/types';
styles, import LoadingModal from '$lib/components/LoadingModal.svelte';
getStylesByCategory,
getIntensityConfig,
} from "$lib/styles";
import type { Style, StyleCategory, ConversionResponse } from "$lib/types";
import LoadingModal from "$lib/components/LoadingModal.svelte";
let inputText = $state(""); const STORAGE_KEY = 'english-styler-state';
let selectedCategoryId = $state("");
let selectedStyleId = $state("");
let intensity = $state(3);
let outputText = $state("");
let loading = $state(false);
let error = $state("");
let systemPrompt = $state("");
let userMessage = $state("");
let modelName = $state("");
let showPrompt = $state(false);
let copied = $state(false);
let availableStyles = $derived( interface SavedState {
selectedCategoryId ? getStylesByCategory(selectedCategoryId) : [], inputText: string;
); selectedCategoryId: string;
selectedStyleId: string;
intensity: number;
showPrompt: boolean;
}
let canConvert = $derived( function loadState(): SavedState | null {
inputText.trim().length > 0 && selectedStyleId.length > 0 && !loading, try {
); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as SavedState;
} catch {
return null;
}
}
let intensityLabel = $derived(getIntensityConfig(intensity)?.label ?? ""); function saveState() {
try {
const state: SavedState = {
inputText,
selectedCategoryId,
selectedStyleId,
intensity,
showPrompt
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// localStorage may be unavailable (private browsing, etc.)
}
}
function onCategoryChange() { const saved = loadState();
selectedStyleId = "";
if (availableStyles.length === 1) {
selectedStyleId = availableStyles[0].id;
}
}
async function handleConvert() { let inputText = $state(saved?.inputText ?? '');
if (!canConvert) return; let selectedCategoryId = $state(saved?.selectedCategoryId ?? '');
let selectedStyleId = $state(saved?.selectedStyleId ?? '');
let intensity = $state(saved?.intensity ?? 3);
let outputText = $state('');
let loading = $state(false);
let error = $state('');
let systemPrompt = $state('');
let userMessage = $state('');
let modelLabel = $state('');
let showPrompt = $state(saved?.showPrompt ?? false);
let copied = $state(false);
loading = true; let availableStyles = $derived(
error = ""; selectedCategoryId ? getStylesByCategory(selectedCategoryId) : []
outputText = ""; );
systemPrompt = "";
userMessage = "";
modelName = "";
showPrompt = false;
try { let canConvert = $derived(
const res = await fetch("/api/convert", { inputText.trim().length > 0 && selectedStyleId.length > 0 && !loading
method: "POST", );
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: inputText,
styleId: selectedStyleId,
intensity,
}),
});
const data = await res.json(); let intensityLabel = $derived(getIntensityConfig(intensity)?.label ?? '');
if (!res.ok) { // Persist state whenever it changes
throw new Error(data.error || "Conversion failed"); $effect(() => {
} saveState();
});
const result: ConversionResponse = data; function onCategoryChange() {
outputText = result.converted; selectedStyleId = '';
systemPrompt = result.systemPrompt; if (availableStyles.length === 1) {
userMessage = result.userMessage; selectedStyleId = availableStyles[0].id;
modelName = result.model; }
} catch (err) { }
error = err instanceof Error ? err.message : "Something went wrong";
} finally {
loading = false;
}
}
async function handleCopy() { async function handleConvert() {
try { if (!canConvert) return;
await navigator.clipboard.writeText(outputText);
copied = true; if (typeof umami !== 'undefined') {
setTimeout(() => (copied = false), 2000); umami.track('convert_click', { style: selectedStyleId, intensity });
} catch { }
// Fallback: select text
const textarea = document.querySelector(".output-text"); loading = true;
if (textarea instanceof HTMLElement) { error = '';
const range = document.createRange(); outputText = '';
range.selectNodeContents(textarea); systemPrompt = '';
const sel = window.getSelection(); userMessage = '';
sel?.removeAllRanges(); modelLabel = '';
sel?.addRange(range); showPrompt = false;
}
} try {
} const res = await fetch('/api/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: inputText,
styleId: selectedStyleId,
intensity
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Conversion failed');
}
const result: ConversionResponse = data;
outputText = result.converted;
systemPrompt = result.systemPrompt;
userMessage = result.userMessage;
modelLabel = result.modelLabel;
if (typeof umami !== 'undefined') {
umami.track('convert_success', { style: selectedStyleId, intensity, model: result.modelLabel });
}
} catch (err) {
error = err instanceof Error ? err.message : 'Something went wrong';
if (typeof umami !== 'undefined') {
umami.track('convert_error', { style: selectedStyleId, intensity, error });
}
} finally {
loading = false;
}
}
async function handleCopy() {
if (typeof umami !== 'undefined') {
umami.track('copy_result', { style: selectedStyleId });
}
try {
await navigator.clipboard.writeText(outputText);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch {
const el = document.querySelector('.output-text');
if (el instanceof HTMLElement) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
}
</script> </script>
<main class="container"> <main class="container">
<h1 class="title">English Style Converter</h1> <h1 class="title">English Style Converter</h1>
<p class="subtitle"> <p class="subtitle">Transform your text into different English styles and tones</p>
Transform your text into different English styles and tones
</p>
<div class="card"> <div class="card">
<div class="form-group"> <div class="form-group">
<label for="input-text">Your Text</label> <label for="input-text">Your Text</label>
<textarea <textarea
id="input-text" id="input-text"
bind:value={inputText} bind:value={inputText}
placeholder="Enter the English text you want to convert... DO NOT ENTER ANY PERSONAL INFORMATION!!" placeholder="Enter the English text you want to convert..."
rows="5" rows="5"
disabled={loading} disabled={loading}
></textarea> ></textarea>
</div> </div>
<div class="selectors"> <div class="selectors">
<div class="form-group"> <div class="form-group">
<label for="category">Style Category</label> <label for="category">Style Category</label>
<select <select
id="category" id="category"
bind:value={selectedCategoryId} bind:value={selectedCategoryId}
onchange={onCategoryChange} onchange={onCategoryChange}
disabled={loading} disabled={loading}
> data-umami-event="select_category"
<option value="">Choose a category...</option> >
{#each categories as cat} <option value="">Choose a category...</option>
<option value={cat.id}>{cat.emoji} {cat.label}</option> {#each categories as cat}
{/each} <option value={cat.id}>{cat.emoji} {cat.label}</option>
</select> {/each}
</div> </select>
</div>
<div class="form-group"> <div class="form-group">
<label for="style">Style</label> <label for="style">Style</label>
<select <select id="style" bind:value={selectedStyleId} disabled={loading || !selectedCategoryId} data-umami-event="select_style">
id="style" {#if !selectedCategoryId}
bind:value={selectedStyleId} <option value="">Select a category first...</option>
disabled={loading || !selectedCategoryId} {:else if availableStyles.length === 0}
> <option value="">No styles available</option>
{#if !selectedCategoryId} {:else}
<option value="">Select a category first...</option> <option value="">Choose a style...</option>
{:else if availableStyles.length === 0} {#each availableStyles as style}
<option value="">No styles available</option> <option value={style.id}>{style.label}</option>
{:else} {/each}
<option value="">Choose a style...</option> {/if}
{#each availableStyles as style} </select>
<option value={style.id}>{style.label}</option> </div>
{/each} </div>
{/if}
</select>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="intensity"> <label for="intensity">
Intensity: <span class="intensity-label" Intensity: <span class="intensity-label">{intensityLabel || 'Strong'}</span>
>{intensityLabel || "Strong"}</span </label>
> <div class="slider-row">
</label> <span class="slider-end">Subtle</span>
<div class="slider-row"> <input
<span class="slider-end">Subtle</span> id="intensity"
<input type="range"
id="intensity" min="1"
type="range" max="5"
min="1" step="1"
max="5" bind:value={intensity}
step="1" disabled={loading}
bind:value={intensity} data-umami-event="adjust_intensity"
disabled={loading} />
/> <span class="slider-end">Maximum</span>
<span class="slider-end">Maximum</span> </div>
</div> </div>
</div>
<button <div class="disclaimer">
class="convert-btn" <span class="disclaimer-icon"></span>
onclick={handleConvert} <span>This tool uses AI to generate styled text. Results may be inaccurate, biased, or unexpected. Do not enter personal or sensitive information. Use at your own discretion — this is a demo and outputs should not be relied upon for important purposes.</span>
disabled={!canConvert} </div>
>
{#if loading}
Converting...
{:else}
✨ Convert
{/if}
</button>
</div>
{#if error} <button class="convert-btn" onclick={handleConvert} disabled={!canConvert}>
<div class="output-card error-card"> {#if loading}
<p class="error-text">⚠️ {error}</p> Converting...
</div> {:else}
{/if} ✨ Convert
{/if}
</button>
</div>
{#if outputText} {#if error}
<div class="output-card"> <div class="output-card error-card">
<div class="output-header"> <p class="error-text">⚠️ {error}</p>
<h3>Result</h3> </div>
<button class="copy-btn" onclick={handleCopy}> {/if}
{#if copied}
✓ Copied!
{:else}
📋 Copy
{/if}
</button>
</div>
<div class="output-text">{outputText}</div>
{#if modelName}
<p class="model-attribution">Responded by {modelName}</p>
{/if}
</div>
<div class="prompt-section"> {#if outputText}
<button <div class="output-card">
class="prompt-toggle" <div class="output-header">
onclick={() => (showPrompt = !showPrompt)} <h3>Result</h3>
> <button class="copy-btn" onclick={handleCopy}>
{showPrompt ? "▼" : "▶"} Show prompt {#if copied}
</button> ✓ Copied!
{#if showPrompt} {:else}
<div class="prompt-content"> 📋 Copy
<div class="prompt-block"> {/if}
<h4>System Prompt</h4> </button>
<pre>{systemPrompt}</pre> </div>
</div> <div class="output-text">{outputText}</div>
<div class="prompt-block"> {#if modelLabel}
<h4>User Message</h4> <p class="model-attribution">Responded by {modelLabel}</p>
<pre>{userMessage}</pre> {/if}
</div> </div>
</div>
{/if} <div class="prompt-section">
</div> <button class="prompt-toggle" onclick={() => (showPrompt = !showPrompt)} data-umami-event="toggle_prompt" data-umami-event-action={showPrompt ? 'close' : 'open'}>
{/if} {showPrompt ? '▼' : '▶'} Show prompt
</button>
{#if showPrompt}
<div class="prompt-content">
<div class="prompt-block">
<h4>System Prompt</h4>
<pre>{systemPrompt}</pre>
</div>
<div class="prompt-block">
<h4>User Message</h4>
<pre>{userMessage}</pre>
</div>
</div>
{/if}
</div>
{/if}
</main> </main>
{#if loading} {#if loading}
<LoadingModal /> <LoadingModal />
{/if} {/if}
<style> <style>
.container { .container {
max-width: 680px; max-width: 680px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
min-height: 100vh; min-height: 100vh;
} }
.title { .title {
font-size: 2rem; font-size: 2rem;
font-weight: 800; font-weight: 800;
color: #1f2937; color: #1f2937;
text-align: center; text-align: center;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.subtitle { .subtitle {
text-align: center; text-align: center;
color: #6b7280; color: #6b7280;
margin-bottom: 2rem; margin-bottom: 2rem;
font-size: 1.05rem; font-size: 1.05rem;
} }
.card { .card {
background: #ffffff; background: #ffffff;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 12px; border-radius: 12px;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
} }
.form-group { .form-group {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.form-group label { .form-group label {
display: block; display: block;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
color: #374151; color: #374151;
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
textarea, textarea,
select { select {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 8px; border-radius: 8px;
font-size: 0.95rem; font-size: 0.95rem;
font-family: inherit; font-family: inherit;
background: #fafafa; background: #fafafa;
color: #1f2937; color: #1f2937;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
textarea:focus, textarea:focus,
select:focus { select:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
textarea { textarea {
resize: vertical; resize: vertical;
min-height: 100px; min-height: 100px;
} }
.selectors { .selectors {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 1rem;
} }
.intensity-label { .intensity-label {
color: #3b82f6; color: #3b82f6;
font-weight: 700; font-weight: 700;
} }
.slider-row { .slider-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.slider-end { .slider-end {
font-size: 0.8rem; font-size: 0.8rem;
color: #9ca3af; color: #9ca3af;
white-space: nowrap; white-space: nowrap;
} }
input[type="range"] { input[type='range'] {
flex: 1; flex: 1;
} }
.convert-btn { .disclaimer {
width: 100%; background: #fff8ed;
padding: 0.85rem; border: 1px solid #f6c96a;
background: #3b82f6; border-radius: 8px;
color: #ffffff; padding: 0.75rem 1rem;
border: none; margin-bottom: 1.25rem;
border-radius: 8px; font-size: 0.82rem;
font-size: 1.05rem; line-height: 1.5;
font-weight: 700; color: #8b6914;
cursor: pointer; display: flex;
transition: gap: 0.6rem;
background 0.2s, align-items: flex-start;
opacity 0.2s; }
}
.convert-btn:hover:not(:disabled) { .disclaimer-icon {
background: #2563eb; flex-shrink: 0;
} font-size: 1rem;
line-height: 1.5;
}
.convert-btn:disabled { .convert-btn {
opacity: 0.5; width: 100%;
cursor: not-allowed; padding: 0.85rem;
} background: #3b82f6;
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1.05rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.output-card { .convert-btn:hover:not(:disabled) {
margin-top: 1.5rem; background: #2563eb;
background: #ffffff; }
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.error-card { .convert-btn:disabled {
border-color: #fca5a5; opacity: 0.5;
background: #fef2f2; cursor: not-allowed;
} }
.error-text { .output-card {
color: #dc2626; margin-top: 1.5rem;
font-weight: 500; background: #ffffff;
} border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.output-header { .error-card {
display: flex; border-color: #fca5a5;
justify-content: space-between; background: #fef2f2;
align-items: center; }
margin-bottom: 0.75rem;
}
.output-header h3 { .error-text {
margin: 0; color: #dc2626;
font-size: 1.1rem; font-weight: 500;
color: #1f2937; }
}
.copy-btn { .output-header {
padding: 0.35rem 0.75rem; display: flex;
background: #f3f4f6; justify-content: space-between;
border: 1px solid #d1d5db; align-items: center;
border-radius: 6px; margin-bottom: 0.75rem;
font-size: 0.85rem; }
cursor: pointer;
transition: background 0.2s;
}
.copy-btn:hover { .output-header h3 {
background: #e5e7eb; margin: 0;
} font-size: 1.1rem;
color: #1f2937;
}
.output-text { .copy-btn {
white-space: pre-wrap; padding: 0.35rem 0.75rem;
line-height: 1.6; background: #f3f4f6;
color: #1f2937; border: 1px solid #d1d5db;
font-size: 1rem; border-radius: 6px;
} font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s;
}
.model-attribution { .copy-btn:hover {
margin-top: 0.75rem; background: #e5e7eb;
font-size: 0.8rem; }
color: #9ca3af;
font-style: italic;
}
.prompt-section { .output-text {
margin-top: 1rem; white-space: pre-wrap;
} line-height: 1.6;
color: #1f2937;
font-size: 1rem;
}
.prompt-toggle { .model-attribution {
background: none; margin-top: 0.75rem;
border: none; font-size: 0.8rem;
color: #3b82f6; color: #9ca3af;
font-size: 0.9rem; font-style: italic;
font-weight: 600; }
cursor: pointer;
padding: 0.5rem 0;
}
.prompt-toggle:hover { .prompt-section {
text-decoration: underline; margin-top: 1rem;
} }
.prompt-content { .prompt-toggle {
margin-top: 0.75rem; background: none;
} border: none;
color: #3b82f6;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0.5rem 0;
}
.prompt-block { .prompt-toggle:hover {
margin-bottom: 1rem; text-decoration: underline;
} }
.prompt-block h4 { .prompt-content {
font-size: 0.85rem; margin-top: 0.75rem;
color: #6b7280; }
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.prompt-block pre { .prompt-block {
background: #f9fafb; margin-bottom: 1rem;
border: 1px solid #e5e7eb; }
border-radius: 8px;
padding: 0.75rem;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
color: #374151;
margin: 0;
}
@media (max-width: 600px) { .prompt-block h4 {
.selectors { font-size: 0.85rem;
grid-template-columns: 1fr; color: #6b7280;
} margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.container { .prompt-block pre {
padding: 1rem; background: #f9fafb;
} border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
color: #374151;
margin: 0;
}
.title { @media (max-width: 600px) {
font-size: 1.5rem; .selectors {
} grid-template-columns: 1fr;
} }
</style>
.container {
padding: 1rem;
}
.title {
font-size: 1.5rem;
}
}
</style>

View File

@@ -54,7 +54,9 @@ export const POST: RequestHandler = async ({ request }) => {
intensity, intensity,
systemPrompt: result.publicSystemPrompt, systemPrompt: result.publicSystemPrompt,
userMessage: result.publicUserMessage, userMessage: result.publicUserMessage,
model: result.model modelLabel: result.actualModel
? `${result.actualModel} model from ${result.requestedModel}`
: result.requestedModel
}; };
return json(response); return json(response);