diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f28def6 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# LLM Configuration (OpenAI-compatible API) +# Default: Ollama running locally + +OPENAI_BASE_URL=http://localhost:11434/v1 +OPENAI_API_KEY=ollama +OPENAI_MODEL=llama3 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 992fa70..9bb5d93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,32 +14,8 @@ "svelte": "^5.55.2", "svelte-check": "^4.4.6", "typescript": "^6.0.2", - "vite": "^8.0.7" - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" + "vite": "^8.0.7", + "vitest": "^4.1.3" } }, "node_modules/@emnapi/wasi-threads": { @@ -505,6 +481,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -512,6 +499,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -526,6 +520,119 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -550,6 +657,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -560,6 +677,16 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -586,6 +713,13 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -623,6 +757,13 @@ "dev": true, "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -648,6 +789,26 @@ } } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1029,6 +1190,13 @@ ], "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1147,6 +1315,13 @@ "dev": true, "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -1172,6 +1347,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/svelte": { "version": "5.55.3", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.3.tgz", @@ -1225,6 +1414,23 @@ "typescript": ">=5.0.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1242,6 +1448,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -1374,6 +1590,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index cdc42a2..3173610 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:unit": "vitest", + "test": "npm run test:unit -- --run" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", @@ -18,6 +20,7 @@ "svelte": "^5.55.2", "svelte-check": "^4.4.6", "typescript": "^6.0.2", - "vite": "^8.0.7" + "vite": "^8.0.7", + "vitest": "^4.1.3" } } diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..1cd6d82 --- /dev/null +++ b/src/app.css @@ -0,0 +1,27 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); + +:global(body) { + margin: 0; + padding: 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f9fafb; + color: #1f2937; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:global(*) { + box-sizing: border-box; +} + +:global(h1, h2, h3, h4, h5, h6) { + margin: 0; +} + +:global(button) { + font-family: inherit; +} + +:global(textarea) { + font-family: inherit; +} \ No newline at end of file diff --git a/src/app.html b/src/app.html index 6a2bb58..9b7d8e3 100644 --- a/src/app.html +++ b/src/app.html @@ -1,5 +1,7 @@ + + English Style Converter diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg deleted file mode 100644 index cc5dc66..0000000 --- a/src/lib/assets/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -svelte-logo \ No newline at end of file diff --git a/src/lib/components/LoadingModal.svelte b/src/lib/components/LoadingModal.svelte new file mode 100644 index 0000000..c693c4e --- /dev/null +++ b/src/lib/components/LoadingModal.svelte @@ -0,0 +1,256 @@ + + + + + \ No newline at end of file diff --git a/src/lib/llm.test.ts b/src/lib/llm.test.ts new file mode 100644 index 0000000..351b372 --- /dev/null +++ b/src/lib/llm.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { buildSystemPrompt, buildUserMessage } from '$lib/llm'; + +describe('buildSystemPrompt', () => { + it('fills in {style} placeholder from intensity instruction', () => { + const result = buildSystemPrompt( + 'Rewrite in a sarcastic, snarky tone with biting wit', + 'rewrite strongly in a {style} style' + ); + expect(result).toContain('rewrite strongly in a Rewrite in a sarcastic, snarky tone with biting wit style'); + expect(result).not.toContain('{style}'); + }); + + it('includes the style modifier on its own line', () => { + const result = buildSystemPrompt('some modifier', 'lightly hint at a {style} tone'); + expect(result).toContain('some modifier'); + }); + + it('includes the core instruction text', () => { + const result = buildSystemPrompt('test', 'rewrite with a {style} tone'); + expect(result).toContain('You are an expert English style converter'); + expect(result).toContain('Output ONLY the converted text'); + }); +}); + +describe('buildUserMessage', () => { + it('returns the text as-is', () => { + expect(buildUserMessage('Hello world')).toBe('Hello world'); + }); + + it('preserves whitespace', () => { + expect(buildUserMessage(' spaced ')).toBe(' spaced '); + }); +}); \ No newline at end of file diff --git a/src/lib/llm.ts b/src/lib/llm.ts new file mode 100644 index 0000000..9e9552e --- /dev/null +++ b/src/lib/llm.ts @@ -0,0 +1,77 @@ +import { env } from '$env/dynamic/private'; +import type { LLMConfig } from './types'; + +const DEFAULT_CONFIG: LLMConfig = { + baseUrl: 'http://localhost:11434/v1', + apiKey: 'ollama', + model: 'llama3' +}; + +function getConfig(): LLMConfig { + return { + baseUrl: env.OPENAI_BASE_URL || DEFAULT_CONFIG.baseUrl, + apiKey: env.OPENAI_API_KEY || DEFAULT_CONFIG.apiKey, + model: env.OPENAI_MODEL || DEFAULT_CONFIG.model + }; +} + +export interface ConvertResult { + converted: string; + systemPrompt: string; + userMessage: string; +} + +export function buildSystemPrompt(styleModifier: string, intensityInstruction: string): string { + const intensityFilled = intensityInstruction.replace('{style}', styleModifier); + return `You are an expert English style converter. +${intensityFilled}. +${styleModifier} +Preserve the core meaning but fully transform the voice and tone. +Output ONLY the converted text — no explanations, no labels, no quotes.`; +} + +export function buildUserMessage(text: string): string { + return text; +} + +export async function convertText( + text: string, + styleModifier: string, + intensityInstruction: string, +overrides?: Partial +): Promise { + const merged: LLMConfig = { ...DEFAULT_CONFIG, ...getConfig(), ...overrides }; + + const systemPrompt = buildSystemPrompt(styleModifier, intensityInstruction); + const userMessage = buildUserMessage(text); + + const response = await fetch(`${merged.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${merged.apiKey}` + }, + body: JSON.stringify({ + model: merged.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage } + ], + temperature: 0.8 + }) + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`LLM request failed (${response.status}): ${errorText}`); + } + + const data = await response.json(); + const converted = data.choices?.[0]?.message?.content?.trim(); + + if (!converted) { + throw new Error('LLM returned empty response'); + } + + return { converted, systemPrompt, userMessage }; +} \ No newline at end of file diff --git a/src/lib/styles.test.ts b/src/lib/styles.test.ts new file mode 100644 index 0000000..b064e2a --- /dev/null +++ b/src/lib/styles.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { + styles, + categories, + intensityMap, + getStylesByCategory, + getStyleById, + getCategoryById, + getIntensityConfig +} from '$lib/styles'; + +describe('styles', () => { + it('all styles have valid ids', () => { + for (const style of styles) { + expect(style.id).toBeTruthy(); + expect(typeof style.id).toBe('string'); + } + }); + + it('all styles have valid promptModifiers', () => { + for (const style of styles) { + expect(style.promptModifier).toBeTruthy(); + expect(typeof style.promptModifier).toBe('string'); + expect(style.promptModifier.length).toBeGreaterThan(5); + } + }); + + it('all styles reference existing categories', () => { + const categoryIds = new Set(categories.map((c) => c.id)); + for (const style of styles) { + expect(categoryIds.has(style.categoryId)).toBe(true); + } + }); + + it('all style ids are unique', () => { + const ids = styles.map((s) => s.id); + const uniqueIds = new Set(ids); + expect(ids.length).toBe(uniqueIds.size); + }); +}); + +describe('getStylesByCategory', () => { + it('returns styles for a known category', () => { + const result = getStylesByCategory('general'); + expect(result.length).toBeGreaterThan(0); + for (const style of result) { + expect(style.categoryId).toBe('general'); + } + }); + + it('returns empty array for unknown category', () => { + const result = getStylesByCategory('nonexistent'); + expect(result).toEqual([]); + }); + + it('general category has 6 styles', () => { + const result = getStylesByCategory('general'); + expect(result.length).toBe(6); + }); +}); + +describe('getStyleById', () => { + it('returns a style for a valid id', () => { + const result = getStyleById('sarcastic'); + expect(result).toBeDefined(); + expect(result!.id).toBe('sarcastic'); + }); + + it('returns undefined for unknown id', () => { + const result = getStyleById('nonexistent'); + expect(result).toBeUndefined(); + }); +}); + +describe('getCategoryById', () => { + it('returns a category for a valid id', () => { + const result = getCategoryById('general'); + expect(result).toBeDefined(); + expect(result!.id).toBe('general'); + }); + + it('returns undefined for unknown id', () => { + const result = getCategoryById('nonexistent'); + expect(result).toBeUndefined(); + }); +}); + +describe('getIntensityConfig', () => { + it('returns config for valid intensity levels 1-5', () => { + for (let i = 1; i <= 5; i++) { + const config = getIntensityConfig(i); + expect(config).toBeDefined(); + expect(config!.label).toBeTruthy(); + expect(config!.instruction).toContain('{style}'); + } + }); + + it('returns undefined for intensity 0', () => { + expect(getIntensityConfig(0)).toBeUndefined(); + }); + + it('returns undefined for intensity 6', () => { + expect(getIntensityConfig(6)).toBeUndefined(); + }); + + it('all intensity instructions contain {style} placeholder', () => { + for (let i = 1; i <= 5; i++) { + const config = getIntensityConfig(i); + expect(config!.instruction).toContain('{style}'); + } + }); +}); \ No newline at end of file diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 1113aba..4912554 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -40,7 +40,7 @@ export const styles: Style[] = [ { id: 'gen-z', label: 'Gen Z Slang', categoryId: 'fun', promptModifier: 'Rewrite in Gen Z slang with no cap, fr, and modern internet vernacular' }, // Game of Thrones - { id: 'got-kings-landing', label: "King's Landing", categoryId: 'got', promptModifier: 'Rewrite as a scheming noble from King'\''s Landing would speak' }, + { id: 'got-kings-landing', label: "King's Landing", categoryId: 'got', promptModifier: 'Rewrite as a scheming noble from Kings Landing would speak' }, { id: 'got-wildlings', label: 'Wildlings', categoryId: 'got', promptModifier: 'Rewrite in the rough, free-spirited manner of the Free Folk wildlings' }, { id: 'got-winterfell', label: 'Winterfell', categoryId: 'got', promptModifier: 'Rewrite with the honor-bound stoicism of a Stark from Winterfell' }, diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9cebde5..828a2f5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,11 @@ - + -{@render children()} +{@render children()} \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..381ebe1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,445 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + +
+

English Style Converter

+

Transform your text into different English styles and tones

+ +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ Subtle + + Maximum +
+
+ + +
+ + {#if error} +
+

⚠️ {error}

+
+ {/if} + + {#if outputText} +
+
+

Result

+ +
+
{outputText}
+
+ +
+ + {#if showPrompt} +
+
+

System Prompt

+
{systemPrompt}
+
+
+

User Message

+
{userMessage}
+
+
+ {/if} +
+ {/if} +
+ +{#if loading} + +{/if} + + \ No newline at end of file diff --git a/src/routes/api/convert/+server.ts b/src/routes/api/convert/+server.ts new file mode 100644 index 0000000..59b2ff1 --- /dev/null +++ b/src/routes/api/convert/+server.ts @@ -0,0 +1,62 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getStyleById, getIntensityConfig } from '$lib/styles'; +import { convertText } from '$lib/llm'; +import type { ConversionRequest, ConversionResponse } from '$lib/types'; + +export const POST: RequestHandler = async ({ request }) => { + let body: ConversionRequest; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { text, styleId, intensity } = body; + + // Validate text + if (!text || typeof text !== 'string' || text.trim().length === 0) { + return json({ error: 'Text is required and must be non-empty' }, { status: 400 }); + } + + // Validate styleId + if (!styleId || typeof styleId !== 'string') { + return json({ error: 'styleId is required' }, { status: 400 }); + } + + const style = getStyleById(styleId); + if (!style) { + return json({ error: `Unknown style: ${styleId}` }, { status: 400 }); + } + + // Validate intensity + if (typeof intensity !== 'number' || !Number.isInteger(intensity) || intensity < 1 || intensity > 5) { + return json({ error: 'Intensity must be an integer between 1 and 5' }, { status: 400 }); + } + + const intensityConfig = getIntensityConfig(intensity); + if (!intensityConfig) { + return json({ error: 'Invalid intensity level' }, { status: 400 }); + } + + try { + const result = convertText(text, style.promptModifier, intensityConfig.instruction); + + // Await the promise + const { converted, systemPrompt, userMessage } = await result; + + const response: ConversionResponse = { + original: text, + converted, + styleId, + intensity, + systemPrompt, + userMessage + }; + + return json(response); + } catch (err) { + const message = err instanceof Error ? err.message : 'LLM call failed'; + return json({ error: `Failed to convert text: ${message}` }, { status: 502 }); + } +}; \ No newline at end of file diff --git a/src/routes/api/convert/server.test.ts b/src/routes/api/convert/server.test.ts new file mode 100644 index 0000000..d32f639 --- /dev/null +++ b/src/routes/api/convert/server.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest'; + +// We test the validation logic by importing the handler indirectly +// Since SvelteKit route handlers are hard to unit test directly, +// we test the underlying functions that handle validation + +import { getStyleById, getIntensityConfig } from '$lib/styles'; + +describe('API validation logic', () => { + it('returns undefined for invalid style id', () => { + expect(getStyleById('nonexistent')).toBeUndefined(); + }); + + it('returns valid style for known id', () => { + expect(getStyleById('sarcastic')).toBeDefined(); + }); + + it('returns undefined for out-of-range intensity', () => { + expect(getIntensityConfig(0)).toBeUndefined(); + expect(getIntensityConfig(6)).toBeUndefined(); + }); + + it('returns valid config for in-range intensity', () => { + for (let i = 1; i <= 5; i++) { + expect(getIntensityConfig(i)).toBeDefined(); + } + }); + + describe('request body validation', () => { + it('rejects empty text', () => { + const text = '' as string; + expect(!text || text.trim().length === 0).toBe(true); + }); + + it('rejects whitespace-only text', () => { + const text = ' '; + expect(text.trim().length === 0).toBe(true); + }); + + it('rejects non-integer intensity', () => { + const intensity = 3.5; + expect(!Number.isInteger(intensity)).toBe(true); + }); + + it('rejects intensity below 1', () => { + const intensity = 0; + expect(intensity < 1).toBe(true); + }); + + it('rejects intensity above 5', () => { + const intensity = 6; + expect(intensity > 5).toBe(true); + }); + + it('accepts valid inputs', () => { + const text = 'Hello world'; + const styleId = 'sarcastic'; + const intensity = 3; + const style = getStyleById(styleId); + const intensityConfig = getIntensityConfig(intensity); + + expect(text.trim().length > 0).toBe(true); + expect(style).toBeDefined(); + expect(Number.isInteger(intensity)).toBe(true); + expect(intensity >= 1 && intensity <= 5).toBe(true); + expect(intensityConfig).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index bbf8c7d..c5f2bba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,20 @@ +import { defineConfig } from 'vitest/config'; import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit()] + plugins: [sveltekit()], + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: './vite.config.ts', + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } });