diff --git a/env.d.ts b/env.d.ts index 18bdc8fe..a81d887c 100644 --- a/env.d.ts +++ b/env.d.ts @@ -4,6 +4,7 @@ interface ImportMetaEnv { VITE_PLAUSIBLE_API_HOST: string; VITE_PLAUSIBLE_DOMAIN: string; + VITE_AI_GATEWAY_API_KEY?: string; PACKAGE_VERSION: string; GIT_SHORT_SHA: string; PROD: boolean; diff --git a/extension/toolbar.html b/extension/toolbar.html index b5619ff0..589277c0 100644 --- a/extension/toolbar.html +++ b/extension/toolbar.html @@ -5,36 +5,50 @@ DevTools toolbar @@ -60,6 +74,22 @@ diff --git a/extension/toolbar.js b/extension/toolbar.js index 62d3cb60..500a1ce4 100644 --- a/extension/toolbar.js +++ b/extension/toolbar.js @@ -64,8 +64,8 @@ document.getElementById('inspectBtn').addEventListener('click', () => sendMessag document.getElementById('screenshotBtn').addEventListener('click', () => sendMessage('takeScreenshot')); document.getElementById('hideBtn').addEventListener('click', hideToolbar); menuSearch.addEventListener('input', (event) => renderTools(event.target.value)); -toolSections.addEventListener('click', (event) => { - const item = event.target.closest('.menu-item'); +function handleToolClick(event) { + const item = event.target.closest('[data-path], [data-id]'); if (!item) return; const id = item.dataset.id; if (id === 'search') return openMenu(true); @@ -74,8 +74,10 @@ toolSections.addEventListener('click', (event) => { if (id === 'hide') return hideToolbar(); if (id === 'preferences') return browserAPI.runtime.sendMessage({ action: 'openOptions' }); if (item.dataset.path !== '') openTool(item.dataset.path); - else openTool(''); -}); +} + +toolSections.addEventListener('click', handleToolClick); +document.querySelector('.shortcut-row').addEventListener('click', handleToolClick); document.addEventListener('click', (event) => { if (!toolbar.contains(event.target)) closeMenu(); }); document.addEventListener('keydown', (event) => { const key = event.key.toLowerCase(); diff --git a/src/App.vue b/src/App.vue index 735fea27..58bce114 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,6 +4,7 @@ import { NGlobalStyle, NMessageProvider, NNotificationProvider, darkTheme } from import { darkThemeOverrides, lightThemeOverrides } from './themes'; import { layouts } from './layouts'; import { useStyleStore } from './stores/style.store'; +import VercelToolbar from './components/VercelToolbar.vue'; const route = useRoute(); const layout = computed(() => route?.meta?.layout ?? layouts.base); @@ -27,6 +28,7 @@ syncRef(locale, useStorage('locale', locale)); + diff --git a/src/components/AiGatewaySettings.vue b/src/components/AiGatewaySettings.vue new file mode 100644 index 00000000..37791f00 --- /dev/null +++ b/src/components/AiGatewaySettings.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/components/VercelToolbar.vue b/src/components/VercelToolbar.vue new file mode 100644 index 00000000..80f2ffa9 --- /dev/null +++ b/src/components/VercelToolbar.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/composable/aiGatewayKey.test.ts b/src/composable/aiGatewayKey.test.ts new file mode 100644 index 00000000..d1f3f2ce --- /dev/null +++ b/src/composable/aiGatewayKey.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { nextTick } from 'vue'; +import { aiGatewayApiKey, aiGatewayApiKeyStorageKey, clearAiGatewayApiKey, hasAiGateway, setAiGatewayApiKey } from './aiGatewayKey'; + +describe('aiGatewayKey', () => { + it('Has no key configured by default', () => { + clearAiGatewayApiKey(); + + expect(hasAiGateway.value).to.equal(false); + expect(aiGatewayApiKey.value).to.equal(''); + }); + + it('Stores the key entered by the user', async () => { + setAiGatewayApiKey(' my-key '); + + expect(aiGatewayApiKey.value).to.equal('my-key'); + expect(hasAiGateway.value).to.equal(true); + + // useStorage persists on the next flush + await nextTick(); + expect(localStorage.getItem(aiGatewayApiKeyStorageKey)).to.equal('my-key'); + }); + + it('Clears the stored key', () => { + setAiGatewayApiKey('my-key'); + clearAiGatewayApiKey(); + + expect(hasAiGateway.value).to.equal(false); + }); +}); diff --git a/src/composable/aiGatewayKey.ts b/src/composable/aiGatewayKey.ts new file mode 100644 index 00000000..69fc805b --- /dev/null +++ b/src/composable/aiGatewayKey.ts @@ -0,0 +1,26 @@ +import { useStorage } from '@vueuse/core'; + +export const aiGatewayApiKeyStorageKey = 'ai-gateway:api-key'; + +/** + * Key baked at build time, for self-hosted deployments where the site operator owns the key. + * It is public by nature, so a key entered by the user always takes precedence over it. + */ +const envApiKey = (import.meta.env.VITE_AI_GATEWAY_API_KEY as string | undefined) ?? ''; + +/** Key entered by the user in the interface, kept in local storage and never shipped in the bundle */ +const storedApiKey = useStorage(aiGatewayApiKeyStorageKey, ''); + +export const aiGatewayApiKey = computed(() => storedApiKey.value.trim() || envApiKey.trim()); + +export const isAiGatewayKeyFromEnv = computed(() => !storedApiKey.value.trim() && envApiKey.trim() !== ''); + +export const hasAiGateway = computed(() => aiGatewayApiKey.value !== ''); + +export function setAiGatewayApiKey(apiKey: string) { + storedApiKey.value = apiKey.trim(); +} + +export function clearAiGatewayApiKey() { + storedApiKey.value = ''; +} diff --git a/src/tools/generate-image/generate-image.e2e.spec.ts b/src/tools/generate-image/generate-image.e2e.spec.ts new file mode 100644 index 00000000..71768da2 --- /dev/null +++ b/src/tools/generate-image/generate-image.e2e.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - Generate image', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/generate-image'); + }); + + test('Has title', async ({ page }) => { + await expect(page).toHaveTitle('Generate image - Dev Tools'); + }); + + test('Generates an image and displays it', async ({ page }) => { + await page.getByRole('button', { name: 'Generate image' }).click(); + + await expect(page.getByText('Generated image')).toBeVisible(); + await expect(page.getByAltText('Generated image')).toBeVisible(); + }); + + test('Disables the generate button when the prompt is empty', async ({ page }) => { + await page.getByPlaceholder('Describe the image you want to generate...').fill(''); + + await expect(page.getByRole('button', { name: 'Generate image' })).toBeDisabled(); + }); +}); diff --git a/src/tools/generate-image/generate-image.service.test.ts b/src/tools/generate-image/generate-image.service.test.ts new file mode 100644 index 00000000..9c6155ce --- /dev/null +++ b/src/tools/generate-image/generate-image.service.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { buildDemoImage, buildImagePrompt, generateImage } from './generate-image.service'; +import { base64ToText } from '@/utils/base64'; + +const options = { + prompt: 'A lighthouse at dusk', + model: 'google/imagen-3', + size: '1024x1024', + style: 'natural', +} as const; + +describe('generate-image', () => { + it('Builds a prompt including the style and the aspect ratio', () => { + const prompt = buildImagePrompt(options); + + expect(prompt).toContain('A lighthouse at dusk'); + expect(prompt).toContain('Style: natural'); + expect(prompt).toContain('1024x1024'); + }); + + it('Renders a deterministic svg data url', () => { + const first = buildDemoImage(options); + const second = buildDemoImage(options); + + expect(first).to.equal(second); + expect(first.startsWith('data:image/svg+xml;base64,')).to.equal(true); + }); + + it('Uses the requested dimensions', () => { + const image = buildDemoImage({ ...options, size: '512x512' }); + const svg = base64ToText(image); + + expect(svg).toContain('width="512"'); + expect(svg).toContain('height="512"'); + }); + + it('Escapes xml special characters of the prompt', () => { + // A prompt without keywords falls back to the raw prompt text in the artwork + const image = buildDemoImage({ ...options, prompt: '&"x"' }); + const svg = base64ToText(image); + + expect(svg).not.toContain(''); + expect(svg).toContain('<b>&"x"'); + }); + + it('Falls back to the demo generator when no gateway key is configured', async () => { + const result = await generateImage(options); + + expect(result.source).to.equal('demo'); + expect(result.image.startsWith('data:image/svg+xml;base64,')).to.equal(true); + }); +}); diff --git a/src/tools/generate-image/generate-image.service.ts b/src/tools/generate-image/generate-image.service.ts new file mode 100644 index 00000000..65db129c --- /dev/null +++ b/src/tools/generate-image/generate-image.service.ts @@ -0,0 +1,138 @@ +import { callAiGateway, createSeededRandom, extractKeywords } from '@/utils/ai-gateway'; +import { textToBase64 } from '@/utils/base64'; + +export const imageModels = [ + { label: 'Imagen 3', value: 'google/imagen-3' }, + { label: 'GPT-4o image', value: 'openai/gpt-4o-image' }, + { label: 'FLUX.1 schnell', value: 'black-forest-labs/flux-1-schnell' }, +]; + +export const imageSizes = ['1024x1024', '1024x768', '768x1024', '512x512'] as const; + +export const imageStyles = ['natural', 'photographic', 'illustration', '3d render', 'watercolor'] as const; + +export type ImageStyle = typeof imageStyles[number]; + +export interface ImageGenerationOptions { + prompt: string + model: string + size: string + style: ImageStyle +} + +export interface ImageGenerationResult { + source: 'gateway' | 'demo' + /** Image as a data URL, ready to be previewed and downloaded */ + image: string +} + +const [defaultWidth, defaultHeight] = imageSizes[0].split('x').map(Number); + +function hashColor(input: string, saturation: number, lightness: number) { + const random = createSeededRandom(input); + const hue = Math.floor(random() * 360); + const secondHue = (hue + 40 + Math.floor(random() * 80)) % 360; + + return [ + `hsl(${hue}, ${saturation}%, ${lightness}%)`, + `hsl(${secondHue}, ${Math.max(20, saturation - 15)}%, ${Math.min(80, lightness + 10)}%)`, + ] as const; +} + +function escapeXml(text: string) { + return text.replace(/[<>&'"]/g, character => ({ + '<': '<', + '>': '>', + '&': '&', + '\'': ''', + '"': '"', + })[character] ?? character); +} + +/** + * Builds a prompt sent to the model, including the style and the aspect ratio. + */ +export function buildImagePrompt({ prompt, style, size }: Pick) { + return `${prompt}\n\nStyle: ${style}. Aspect ratio: ${size}.`; +} + +/** + * Deterministic, offline image generation, used when no AI Gateway key is configured. + * It renders a gradient artwork derived from the prompt, so the tool stays usable. + */ +function parseSize(size: string): [number, number] { + const dimensions = size.split('x').map(Number); + + if (dimensions.length !== 2 || dimensions.some(dimension => !Number.isFinite(dimension) || dimension <= 0)) { + return [defaultWidth, defaultHeight]; + } + + return [dimensions[0], dimensions[1]]; +} + +export function buildDemoImage({ prompt, style, size }: ImageGenerationOptions) { + const [width, height] = parseSize(size); + const keywords = extractKeywords(prompt); + const [from, to] = hashColor(`${prompt}-${style}`, 70, 55); + const [accentFrom, accentTo] = hashColor(`${style}-${prompt}`, 80, 65); + const title = escapeXml(keywords.slice(0, 4).join(' · ') || prompt.slice(0, 60)); + + const shapes = Array.from({ length: 5 }, (_, index) => { + const random = createSeededRandom(`${prompt}-${index}`); + const cx = Math.round(random() * width); + const cy = Math.round(random() * height); + const radius = Math.round(Math.min(width, height) * (0.08 + random() * 0.18)); + const opacity = (0.15 + random() * 0.25).toFixed(2); + + return ``; + }).join('\n '); + + const svg = ` + + + + + + + + + + + + + + ${shapes} + + ${title} + + + `.trim(); + + return `data:image/svg+xml;base64,${textToBase64(svg)}`; +} + +/** + * Generates an image. Uses the AI Gateway when an API key is configured, + * otherwise falls back to the local demo generator. + */ +export async function generateImage(options: ImageGenerationOptions): Promise { + try { + const { data } = await callAiGateway({ + modality: 'image', + model: options.model, + prompt: buildImagePrompt(options), + size: options.size, + }); + + const image = String(data.image ?? data.url ?? ''); + + if (image === '') { + throw new Error('No image returned by the AI Gateway'); + } + + return { source: 'gateway', image }; + } + catch { + return { source: 'demo', image: buildDemoImage(options) }; + } +} diff --git a/src/tools/generate-image/generate-image.vue b/src/tools/generate-image/generate-image.vue new file mode 100644 index 00000000..a07a8364 --- /dev/null +++ b/src/tools/generate-image/generate-image.vue @@ -0,0 +1,106 @@ + + + diff --git a/src/tools/generate-image/index.ts b/src/tools/generate-image/index.ts new file mode 100644 index 00000000..d441b3a4 --- /dev/null +++ b/src/tools/generate-image/index.ts @@ -0,0 +1,12 @@ +import { Photo } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Generate image', + path: '/generate-image', + description: 'Generate an image from a text prompt with selectable model, size and style', + keywords: ['ai', 'generate', 'image', 'picture', 'art', 'prompt', 'imagen', 'flux', 'diffusion'], + component: () => import('./generate-image.vue'), + icon: Photo, + createdAt: new Date('2026-10-08'), +}); diff --git a/src/tools/generate-object/generate-object.e2e.spec.ts b/src/tools/generate-object/generate-object.e2e.spec.ts new file mode 100644 index 00000000..f9ddff46 --- /dev/null +++ b/src/tools/generate-object/generate-object.e2e.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - Generate object', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/generate-object'); + }); + + test('Has title', async ({ page }) => { + await expect(page).toHaveTitle('Generate object - Dev Tools'); + }); + + test('Generates a JSON object', async ({ page }) => { + await page.getByRole('button', { name: 'Generate object' }).click(); + + await expect(page.getByText('Generated object')).toBeVisible(); + }); + + test('Disables the generate button when the prompt is empty', async ({ page }) => { + await page.getByPlaceholder('Describe the object you want to generate...').fill(''); + + await expect(page.getByRole('button', { name: 'Generate object' })).toBeDisabled(); + }); +}); diff --git a/src/tools/generate-object/generate-object.service.test.ts b/src/tools/generate-object/generate-object.service.test.ts new file mode 100644 index 00000000..8ceb13a3 --- /dev/null +++ b/src/tools/generate-object/generate-object.service.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { buildDemoObject, buildObjectPrompt, generateObject, parseObjectFields } from './generate-object.service'; +import type { ObjectGenerationOptions } from './generate-object.service'; + +describe('generate-object', () => { + it('Parses fields written as a comma or newline separated list', () => { + const fields = parseObjectFields('title: string, score: number\nactive: boolean'); + + expect(fields).toEqual([ + { name: 'title', type: 'string' }, + { name: 'score', type: 'number' }, + { name: 'active', type: 'boolean' }, + ]); + }); + + it('Defaults to a string type for unknown types', () => { + expect(parseObjectFields('weird: blob')).toEqual([{ name: 'weird', type: 'string' }]); + }); + + it('Ignores empty field definitions', () => { + expect(parseObjectFields(' , name: string, ')).toEqual([{ name: 'name', type: 'string' }]); + }); + + it('Builds a prompt listing every field', () => { + const prompt = buildObjectPrompt({ + prompt: 'A blog post', + fields: [{ name: 'title', type: 'string' }], + }); + + expect(prompt).toContain('- title (string)'); + expect(prompt).toContain('A blog post'); + }); + + it('Generates a deterministic object with the requested field types', () => { + const options: ObjectGenerationOptions = { + prompt: 'A blog post about pull requests', + model: 'openai/gpt-4o-mini', + fields: [ + { name: 'title', type: 'string' }, + { name: 'score', type: 'number' }, + { name: 'tags', type: 'array' }, + { name: 'published', type: 'boolean' }, + ], + }; + + const first = buildDemoObject(options); + const second = buildDemoObject(options); + + expect(first).to.deep.equal(second); + expect(first.title).to.be.a('string'); + expect(first.score).to.be.a('number'); + expect(first.tags).to.be.an('array'); + expect(first.published).to.be.a('boolean'); + }); + + it('Falls back to the demo generator when no gateway key is configured', async () => { + const result = await generateObject({ + prompt: 'A blog post about pull requests', + model: 'openai/gpt-4o-mini', + fields: [{ name: 'title', type: 'string' }], + }); + + expect(result.source).to.equal('demo'); + expect(result.object).to.have.property('title'); + }); +}); diff --git a/src/tools/generate-object/generate-object.service.ts b/src/tools/generate-object/generate-object.service.ts new file mode 100644 index 00000000..057365ba --- /dev/null +++ b/src/tools/generate-object/generate-object.service.ts @@ -0,0 +1,135 @@ +import { callAiGateway, capitalize, createSeededRandom, extractKeywords } from '@/utils/ai-gateway'; + +export const objectModels = [ + { label: 'GPT-4o mini', value: 'openai/gpt-4o-mini' }, + { label: 'Claude 3.5 Sonnet', value: 'anthropic/claude-3-5-sonnet' }, + { label: 'Gemini 1.5 Flash', value: 'google/gemini-1.5-flash' }, +]; + +export interface ObjectField { + name: string + type: 'string' | 'number' | 'boolean' | 'array' +} + +export interface ObjectGenerationOptions { + prompt: string + model: string + fields: ObjectField[] +} + +export interface ObjectGenerationResult { + source: 'gateway' | 'demo' + object: Record +} + +export const defaultObjectFields: ObjectField[] = [ + { name: 'title', type: 'string' }, + { name: 'summary', type: 'string' }, + { name: 'score', type: 'number' }, + { name: 'tags', type: 'array' }, + { name: 'isPublished', type: 'boolean' }, +]; + +export const defaultObjectPrompt = 'A blog post about the benefits of small pull requests'; + +export function parseObjectFields(fieldsText: string): ObjectField[] { + return fieldsText + .split(/[\n,]/) + .map(line => line.trim()) + .filter(line => line !== '') + .map((line) => { + const [name, rawType] = line.split(':').map(part => part.trim()); + const type = ['string', 'number', 'boolean', 'array'].includes(rawType) ? rawType : 'string'; + + return { name: name.replace(/\s+/g, '_'), type } as ObjectField; + }) + .filter(field => field.name !== ''); +} + +export function buildObjectPrompt({ prompt, fields }: Pick) { + const schema = fields + .map(field => `- ${field.name} (${field.type})`) + .join('\n'); + + return `Generate a JSON object about the following, using exactly these fields:\n${schema}\n\n${prompt}`; +} + +const adjectives = ['practical', 'concise', 'detailed', 'friendly', 'actionable', 'focused']; +const domains = ['engineering', 'product', 'design', 'operations', 'research']; + +function generateFieldValue({ field, keywords, random }: { field: ObjectField; keywords: string[]; random: () => number }) { + const topic = keywords[0] ?? 'the generated topic'; + const domain = domains[Math.floor(random() * domains.length)]; + + switch (field.type) { + case 'number': { + return Math.floor(random() * 90) + 10; + } + case 'boolean': { + return random() > 0.5; + } + case 'array': { + return keywords.slice(0, 3).length > 0 + ? keywords.slice(0, 3) + : [domain, adjectives[Math.floor(random() * adjectives.length)], 'reference']; + } + case 'string': + default: { + if (/title|name|headline/i.test(field.name)) { + return `${capitalize(adjectives[Math.floor(random() * adjectives.length)])} ${topic} in ${domain}`; + } + if (/summary|description|content|body/i.test(field.name)) { + return `${capitalize(topic)} matters in ${domain} because it keeps the feedback loop short and the scope small.`; + } + + return `${capitalize(topic)} (${domain})`; + } + } +} + +/** + * Deterministic, offline object generation, used when no AI Gateway key is configured. + */ +export function buildDemoObject({ prompt, fields }: ObjectGenerationOptions) { + const random = createSeededRandom(`${prompt}-${fields.map(field => field.name).join('-')}`); + const keywords = extractKeywords(prompt); + + return fields.reduce>( + (object, field) => ({ + ...object, + [field.name]: generateFieldValue({ field, keywords, random }), + }), + {}, + ); +} + +function parseLooseJson(text: string) { + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + + if (start === -1 || end === -1) { + throw new Error('No JSON object found in the model response'); + } + + return JSON.parse(text.slice(start, end + 1)); +} + +/** + * Generates a structured object matching the requested fields. + * Uses the AI Gateway when an API key is configured, otherwise falls back to the local demo generator. + */ +export async function generateObject(options: ObjectGenerationOptions): Promise { + try { + const { data } = await callAiGateway({ + modality: 'object', + model: options.model, + prompt: buildObjectPrompt(options), + schema: options.fields, + }); + + return { source: 'gateway', object: parseLooseJson(String(data.text ?? data.object ?? '')) }; + } + catch { + return { source: 'demo', object: buildDemoObject(options) }; + } +} diff --git a/src/tools/generate-object/generate-object.vue b/src/tools/generate-object/generate-object.vue new file mode 100644 index 00000000..cdefc7c9 --- /dev/null +++ b/src/tools/generate-object/generate-object.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/tools/generate-object/index.ts b/src/tools/generate-object/index.ts new file mode 100644 index 00000000..3dbd8681 --- /dev/null +++ b/src/tools/generate-object/index.ts @@ -0,0 +1,12 @@ +import { Braces } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Generate object', + path: '/generate-object', + description: 'Generate a structured JSON object from a prompt and a list of fields', + keywords: ['ai', 'generate', 'object', 'json', 'schema', 'structured', 'prompt', 'llm'], + component: () => import('./generate-object.vue'), + icon: Braces, + createdAt: new Date('2026-10-08'), +}); diff --git a/src/tools/generate-speech/generate-speech.e2e.spec.ts b/src/tools/generate-speech/generate-speech.e2e.spec.ts new file mode 100644 index 00000000..4435f4b5 --- /dev/null +++ b/src/tools/generate-speech/generate-speech.e2e.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - Generate speech', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/generate-speech'); + }); + + test('Has title', async ({ page }) => { + await expect(page).toHaveTitle('Generate speech - Dev Tools'); + }); + + test('Shows the character count and estimated duration', async ({ page }) => { + await expect(page.getByText(/characters, about \d+s at/)).toBeVisible(); + }); + + test('Disables the generate button when the text is empty', async ({ page }) => { + await page.getByPlaceholder('Type the text you want to hear...').fill(''); + + await expect(page.getByRole('button', { name: 'Generate speech' })).toBeDisabled(); + }); + + test('Falls back to the browser preview when no gateway key is configured', async ({ page }) => { + await page.getByRole('button', { name: 'Generate speech' }).click(); + + await expect(page.getByText('No AI Gateway key configured')).toBeVisible(); + }); + + test('Accepts a key entered by the user', async ({ page }) => { + await page.getByRole('button', { name: 'AI Gateway key' }).click(); + await page.getByPlaceholder('AI Gateway API key...').fill('test-key'); + await page.getByRole('button', { name: 'Save key' }).click(); + + await expect(page.getByText('Using your own key, stored in this browser')).toBeVisible(); + }); +}); diff --git a/src/tools/generate-speech/generate-speech.service.test.ts b/src/tools/generate-speech/generate-speech.service.test.ts new file mode 100644 index 00000000..e4135386 --- /dev/null +++ b/src/tools/generate-speech/generate-speech.service.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSpeechPrompt, + countSpeakableCharacters, + estimateSpeechDuration, + generateSpeech, +} from './generate-speech.service'; + +describe('generate-speech', () => { + it('Collapses whitespace of the prompt', () => { + expect(buildSpeechPrompt({ text: ' hello world \n' })).to.equal('hello world'); + }); + + it('Counts the speakable characters', () => { + expect(countSpeakableCharacters(' hello world ')).to.equal(11); + }); + + it('Estimates a duration based on the number of words and the speed', () => { + const text = 'one two three four five six'; + + expect(estimateSpeechDuration({ text, speed: 1 })).to.equal(2); + expect(estimateSpeechDuration({ text, speed: 2 })).to.equal(1); + }); + + it('Returns no duration for an empty text', () => { + expect(estimateSpeechDuration({ text: ' ', speed: 1 })).to.equal(0); + }); + + it('Falls back to the browser preview when no gateway key is configured', async () => { + const result = await generateSpeech({ text: 'hello world', model: 'openai/gpt-4o-mini-tts', voice: 'alloy', speed: 1 }); + + expect(result.source).to.equal('demo'); + expect(result.audio).to.equal(''); + }); +}); diff --git a/src/tools/generate-speech/generate-speech.service.ts b/src/tools/generate-speech/generate-speech.service.ts new file mode 100644 index 00000000..2dbb1909 --- /dev/null +++ b/src/tools/generate-speech/generate-speech.service.ts @@ -0,0 +1,114 @@ +import { callAiGateway } from '@/utils/ai-gateway'; + +export const speechModels = [ + { label: 'OpenAI TTS', value: 'openai/gpt-4o-mini-tts' }, + { label: 'ElevenLabs', value: 'elevenlabs/eleven-turbo-v2' }, +]; + +export const speechVoices = ['alloy', 'ash', 'coral', 'echo', 'nova', 'onyx', 'shimmer'] as const; + +export type SpeechVoice = typeof speechVoices[number]; + +export interface SpeechGenerationOptions { + text: string + model: string + voice: SpeechVoice + speed: number +} + +export interface SpeechGenerationResult { + source: 'gateway' | 'demo' + /** Base64 or data url of the generated audio, empty when the demo only previews the text */ + audio: string +} + +export const speechFormats = ['mp3', 'wav', 'opus'] as const; + +export function buildSpeechPrompt({ text }: Pick) { + return text.trim().replace(/\s+/g, ' '); +} + +export function estimateSpeechDuration({ text, speed }: Pick) { + const words = text.trim().split(/\s+/).filter(word => word !== ''); + const wordsPerMinute = 150 * speed; + + return words.length === 0 ? 0 : Math.round((words.length / wordsPerMinute) * 60); +} + +/** + * Counts the characters the speech synthesis engine will read, ignoring markup. + */ +export function countSpeakableCharacters(text: string) { + return buildSpeechPrompt({ text }).length; +} + +/** + * Speaks the text with the browser speech synthesis engine. + * Resolves when the playback ends, or immediately when the browser has no support. + */ +export function speakText({ text, voice, speed }: SpeechGenerationOptions): Promise { + const synthesis = typeof window === 'undefined' ? undefined : window.speechSynthesis; + + if (!synthesis || typeof window.SpeechSynthesisUtterance === 'undefined') { + return Promise.resolve(false); + } + + synthesis.cancel(); + + return new Promise((resolve) => { + const utterance = new SpeechSynthesisUtterance(buildSpeechPrompt({ text })); + utterance.rate = speed; + utterance.lang = 'en-US'; + + const matchingVoice = synthesis + .getVoices() + .find(candidate => candidate.name.toLowerCase().includes(voice)); + if (matchingVoice) { + utterance.voice = matchingVoice; + } + + const onEnd = () => { + utterance.onend = null; + utterance.onerror = null; + resolve(true); + }; + + utterance.onend = onEnd; + utterance.onerror = onEnd; + + synthesis.speak(utterance); + }); +} + +export function stopSpeaking() { + if (typeof window !== 'undefined' && window.speechSynthesis) { + window.speechSynthesis.cancel(); + } +} + +/** + * Generates speech. Uses the AI Gateway when an API key is configured, + * otherwise returns no audio and relies on the local browser preview. + */ +export async function generateSpeech(options: SpeechGenerationOptions): Promise { + try { + const { data } = await callAiGateway({ + modality: 'speech', + model: options.model, + prompt: buildSpeechPrompt(options), + voice: options.voice, + speed: options.speed, + }); + + const audio = String(data.audio ?? data.url ?? ''); + + if (audio === '') { + throw new Error('No audio returned by the AI Gateway'); + } + + return { source: 'gateway', audio: audio.startsWith('data:') ? audio : `data:audio/mpeg;base64,${audio}` }; + } + catch { + return { source: 'demo', audio: '' }; + } +} diff --git a/src/tools/generate-speech/generate-speech.vue b/src/tools/generate-speech/generate-speech.vue new file mode 100644 index 00000000..1862a79c --- /dev/null +++ b/src/tools/generate-speech/generate-speech.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/tools/generate-speech/index.ts b/src/tools/generate-speech/index.ts new file mode 100644 index 00000000..ec46b959 --- /dev/null +++ b/src/tools/generate-speech/index.ts @@ -0,0 +1,12 @@ +import { Microphone } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Generate speech', + path: '/generate-speech', + description: 'Turn text into speech with selectable voice, speed and a browser preview', + keywords: ['ai', 'generate', 'speech', 'voice', 'tts', 'text to speech', 'audio', 'narration'], + component: () => import('./generate-speech.vue'), + icon: Microphone, + createdAt: new Date('2026-10-08'), +}); diff --git a/src/tools/generate-text/generate-text.e2e.spec.ts b/src/tools/generate-text/generate-text.e2e.spec.ts new file mode 100644 index 00000000..c4b652a0 --- /dev/null +++ b/src/tools/generate-text/generate-text.e2e.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - Generate text', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/generate-text'); + }); + + test('Has title', async ({ page }) => { + await expect(page).toHaveTitle('Generate text - Dev Tools'); + }); + + test('Generates text from the default prompt', async ({ page }) => { + await page.getByRole('button', { name: 'Generate text' }).click(); + + await expect(page.getByText('Generated text')).toBeVisible(); + }); + + test('Disables the generate button when the prompt is empty', async ({ page }) => { + await page.getByPlaceholder('Describe what you want the model to write...').fill(''); + + await expect(page.getByRole('button', { name: 'Generate text' })).toBeDisabled(); + }); +}); diff --git a/src/tools/generate-text/generate-text.service.test.ts b/src/tools/generate-text/generate-text.service.test.ts new file mode 100644 index 00000000..d27e4ddd --- /dev/null +++ b/src/tools/generate-text/generate-text.service.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { buildDemoText, buildTextPrompt, generateText } from './generate-text.service'; + +describe('generate-text', () => { + it('Builds a prompt including the requested tone', () => { + const prompt = buildTextPrompt({ prompt: 'Explain monorepos', tone: 'formal' }); + + expect(prompt).toContain('formal'); + expect(prompt).toContain('Explain monorepos'); + }); + + it('Generates a deterministic demo text', () => { + const options = { prompt: 'Explain monorepos', model: 'openai/gpt-4o-mini', tone: 'neutral' } as const; + const first = buildDemoText(options); + const second = buildDemoText(options); + + expect(first).to.equal(second); + expect(first).toContain('monorepos'); + }); + + it('Falls back to the demo generator when no gateway key is configured', async () => { + const result = await generateText({ + prompt: 'Explain monorepos', + model: 'openai/gpt-4o-mini', + tone: 'neutral', + }); + + expect(result.source).to.equal('demo'); + expect(result.text).not.to.equal(''); + }); +}); diff --git a/src/tools/generate-text/generate-text.service.ts b/src/tools/generate-text/generate-text.service.ts new file mode 100644 index 00000000..4762afe7 --- /dev/null +++ b/src/tools/generate-text/generate-text.service.ts @@ -0,0 +1,85 @@ +import { callAiGateway, capitalize, createSeededRandom, extractKeywords, pickOne } from '@/utils/ai-gateway'; + +export const textModels = [ + { label: 'GPT-4o mini', value: 'openai/gpt-4o-mini' }, + { label: 'Claude 3.5 Sonnet', value: 'anthropic/claude-3-5-sonnet' }, + { label: 'Gemini 1.5 Flash', value: 'google/gemini-1.5-flash' }, + { label: 'Llama 3.3 70B', value: 'meta/llama-3.3-70b' }, +]; + +export const textTones = ['neutral', 'formal', 'casual', 'persuasive', 'technical'] as const; + +export type TextTone = typeof textTones[number]; + +export interface TextGenerationOptions { + prompt: string + model: string + tone: TextTone +} + +export interface TextGenerationResult { + source: 'gateway' | 'demo' + text: string +} + +const toneOpeners: Record = { + neutral: ['Here is a take on your request:', 'Below is a straightforward response:', 'Summary of what you asked for:'], + formal: ['To summarise the request formally:', 'Please find below a formal response:', 'In formal terms, the response is as follows:'], + casual: ['Sure thing! Here you go:', 'Happy to help with that:', 'Ok, so here is what I came up with:'], + persuasive: ['Here is why this matters:', 'Consider this compelling take:', 'The strongest case looks like this:'], + technical: ['Technical breakdown:', 'Implementation notes:', 'From an engineering standpoint:'], +}; + +const toneConnectors: Record = { + neutral: ['Additionally,', 'Moreover,', 'As a result,'], + formal: ['Furthermore,', 'Consequently,', 'In addition,'], + casual: ['On top of that,', 'Also,', 'And,'], + persuasive: ['That is the key point:', 'More importantly,', 'Which is why it matters:'], + technical: ['Implementation-wise,', 'At the API level,', 'From a performance standpoint,'], +}; + +/** + * Builds the prompt sent to the model, so the tone is part of the instructions. + */ +export function buildTextPrompt({ prompt, tone }: Pick) { + return `Write a ${tone} response about the following topic, in a few short paragraphs:\n\n${prompt}`; +} + +/** + * Deterministic, offline text generation, used when no AI Gateway key is configured. + */ +export function buildDemoText({ prompt, tone }: TextGenerationOptions) { + const keywords = extractKeywords(prompt); + const random = createSeededRandom(`${prompt}-${tone}`); + const topics = keywords.length > 0 ? keywords : ['the topic you described']; + const subject = topics[0]; + const secondary = topics[1] ?? topics[0]; + + const opener = pickOne(toneOpeners[tone], random); + const connector = pickOne(toneConnectors[tone], random); + + return [ + `${opener} ${capitalize(subject)} matters because it shapes how the rest of the work falls into place. When ${subject} is handled early, the decisions that follow tend to be smaller and easier to reverse.`, + `${connector} the practical side of ${secondary} is where most of the value shows up. A short checklist, one worked example, and a clear definition of done usually save more time than any clever trick.`, + `In short: start with ${subject}, keep the scope narrow, and revisit the assumptions around ${secondary} once there is real feedback. That loop is what turns a good intention into a result you can rely on.`, + ].join('\n\n'); +} + +/** + * Generates a text. Uses the AI Gateway when an API key is configured, + * otherwise falls back to the local demo generator. + */ +export async function generateText(options: TextGenerationOptions): Promise { + try { + const { data } = await callAiGateway({ + modality: 'text', + model: options.model, + prompt: buildTextPrompt(options), + }); + + return { source: 'gateway', text: String(data.text ?? '') }; + } + catch { + return { source: 'demo', text: buildDemoText(options) }; + } +} diff --git a/src/tools/generate-text/generate-text.vue b/src/tools/generate-text/generate-text.vue new file mode 100644 index 00000000..6ea66bb9 --- /dev/null +++ b/src/tools/generate-text/generate-text.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/tools/generate-text/index.ts b/src/tools/generate-text/index.ts new file mode 100644 index 00000000..b8aebfe3 --- /dev/null +++ b/src/tools/generate-text/index.ts @@ -0,0 +1,12 @@ +import { FileText } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Generate text', + path: '/generate-text', + description: 'Generate text from a prompt with a selectable model and tone', + keywords: ['ai', 'generate', 'text', 'prompt', 'llm', 'gpt', 'claude', 'writing', 'content'], + component: () => import('./generate-text.vue'), + icon: FileText, + createdAt: new Date('2026-10-08'), +}); diff --git a/src/tools/generate-video/generate-video.e2e.spec.ts b/src/tools/generate-video/generate-video.e2e.spec.ts new file mode 100644 index 00000000..be637c63 --- /dev/null +++ b/src/tools/generate-video/generate-video.e2e.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - Generate video', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/generate-video'); + }); + + test('Has title', async ({ page }) => { + await expect(page).toHaveTitle('Generate video - Dev Tools'); + }); + + test('Generates a storyboard', async ({ page }) => { + await page.getByRole('button', { name: 'Generate video' }).click(); + + await expect(page.getByText('Storyboard')).toBeVisible(); + await expect(page.getByText(/Scene 1/)).toBeVisible(); + }); + + test('Disables the generate button when the prompt is empty', async ({ page }) => { + await page.getByPlaceholder('Describe the video you want to generate...').fill(''); + + await expect(page.getByRole('button', { name: 'Generate video' })).toBeDisabled(); + }); +}); diff --git a/src/tools/generate-video/generate-video.service.test.ts b/src/tools/generate-video/generate-video.service.test.ts new file mode 100644 index 00000000..5eb434e6 --- /dev/null +++ b/src/tools/generate-video/generate-video.service.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDemoStoryboard, + buildVideoPrompt, + countScenes, + generateVideo, +} from './generate-video.service'; + +const options = { + prompt: 'A cyclist crossing a foggy bridge', + model: 'google/veo', + duration: 6, + aspectRatio: '16:9', +} as const; + +describe('generate-video', () => { + it('Builds a prompt including the duration and the aspect ratio', () => { + const prompt = buildVideoPrompt(options); + + expect(prompt).toContain('6s'); + expect(prompt).toContain('16:9'); + }); + + it('Computes a scene count from the duration', () => { + expect(countScenes(6)).to.equal(2); + expect(countScenes(10)).to.equal(3); + expect(countScenes(1)).to.equal(1); + }); + + it('Builds a deterministic storyboard covering the whole duration', () => { + const first = buildDemoStoryboard(options); + const second = buildDemoStoryboard(options); + + expect(first).to.deep.equal(second); + expect(first).to.have.lengthOf(2); + expect(first[0].startSecond).to.equal(0); + expect(first[first.length - 1].startSecond + first[first.length - 1].durationSeconds).to.equal(6); + }); + + it('Falls back to the storyboard when no gateway key is configured', async () => { + const result = await generateVideo(options); + + expect(result.source).to.equal('demo'); + expect(result.video).to.equal(''); + expect(result.scenes.length).to.be.greaterThan(0); + }); +}); diff --git a/src/tools/generate-video/generate-video.service.ts b/src/tools/generate-video/generate-video.service.ts new file mode 100644 index 00000000..b8fcbde0 --- /dev/null +++ b/src/tools/generate-video/generate-video.service.ts @@ -0,0 +1,111 @@ +import { callAiGateway, capitalize, createSeededRandom, extractKeywords, pickOne } from '@/utils/ai-gateway'; + +export const videoModels = [ + { label: 'Veo', value: 'google/veo' }, + { label: 'Kling', value: 'kwaivgi/kling-v1' }, + { label: 'Runway Gen-3', value: 'runway/gen3-turbo' }, +]; + +export const videoDurations = [4, 6, 8, 10] as const; + +export type VideoDuration = typeof videoDurations[number]; + +export const videoAspectRatios = ['16:9', '9:16', '1:1', '4:5'] as const; + +export interface VideoGenerationOptions { + prompt: string + model: string + duration: number + aspectRatio: string +} + +export interface VideoScene { + index: number + startSecond: number + durationSeconds: number + shot: string + cameraMovement: string + description: string +} + +export interface VideoGenerationResult { + source: 'gateway' | 'demo' + /** Video as a data url, empty when only the offline storyboard is available */ + video: string + scenes: VideoScene[] +} + +const shotTypes = ['wide', 'medium', 'close-up', 'aerial', 'over the shoulder']; +const cameraMovements = ['static', 'pan left', 'pan right', 'dolly in', 'dolly out', 'handheld']; +const transitions = ['cut', 'cross dissolve', 'fade', 'match cut']; + +export function buildVideoPrompt({ prompt, duration, aspectRatio }: Pick) { + return `${prompt}\n\nDuration: ${duration}s. Aspect ratio: ${aspectRatio}.`; +} + +export function countScenes(duration: number, sceneLength = 3) { + return Math.max(1, Math.round(duration / sceneLength)); +} + +/** + * Deterministic, offline storyboard, used when no AI Gateway key is configured. + * It turns the prompt into a shot list that can be used as the video brief. + */ +export function buildDemoStoryboard({ prompt, duration }: Pick) { + const keywords = extractKeywords(prompt); + const random = createSeededRandom(prompt); + const subject = keywords[0] ?? 'the subject'; + const detail = keywords[1] ?? keywords[0] ?? 'the setting'; + const sceneCount = countScenes(duration); + const scenesDuration = Math.round((duration / sceneCount) * 10) / 10; + + return Array.from({ length: sceneCount }, (_, index): VideoScene => { + const shot = pickOne(shotTypes, random); + const cameraMovement = pickOne(cameraMovements, random); + const transition = pickOne(transitions, random); + + return { + index: index + 1, + startSecond: Math.round(index * scenesDuration * 10) / 10, + durationSeconds: scenesDuration, + shot, + cameraMovement, + description: index === 0 + ? `Establishing ${shot} shot on ${capitalize(subject)}, with ${detail} setting the mood.` + : `${capitalize(transition)} to a ${shot} shot exploring ${detail}, camera on ${cameraMovement}.`, + }; + }); +} + +function parseScenes(text: string): VideoScene[] { + const parsed = JSON.parse(text); + + return Array.isArray(parsed) ? parsed as VideoScene[] : []; +} + +/** + * Generates a video. Uses the AI Gateway when an API key is configured, + * otherwise falls back to the local storyboard generator. + */ +export async function generateVideo(options: VideoGenerationOptions): Promise { + try { + const { data } = await callAiGateway({ + modality: 'video', + model: options.model, + prompt: buildVideoPrompt(options), + duration: options.duration, + aspectRatio: options.aspectRatio, + }); + + const video = String(data.video ?? data.url ?? ''); + + if (video === '') { + throw new Error('No video returned by the AI Gateway'); + } + + return { source: 'gateway', video, scenes: parseScenes(String(data.scenes ?? '[]')) }; + } + catch { + return { source: 'demo', video: '', scenes: buildDemoStoryboard(options) }; + } +} diff --git a/src/tools/generate-video/generate-video.vue b/src/tools/generate-video/generate-video.vue new file mode 100644 index 00000000..25847f63 --- /dev/null +++ b/src/tools/generate-video/generate-video.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/tools/generate-video/index.ts b/src/tools/generate-video/index.ts new file mode 100644 index 00000000..dfb392b7 --- /dev/null +++ b/src/tools/generate-video/index.ts @@ -0,0 +1,12 @@ +import { Video } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Generate video', + path: '/generate-video', + description: 'Generate a video from a text prompt with selectable model, duration and aspect ratio', + keywords: ['ai', 'generate', 'video', 'storyboard', 'prompt', 'veo', 'kling', 'runway', 'clip'], + component: () => import('./generate-video.vue'), + icon: Video, + createdAt: new Date('2026-10-08'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index f9eddce5..5f28043f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -95,6 +95,11 @@ import { tool as jsonSchemaGenerator } from './json-schema-generator'; import { tool as regexGenerator } from './regex-generator'; import { tool as sqlQueryGenerator } from './sql-query-generator'; import { tool as aiChat } from './ai-chat'; +import { tool as generateText } from './generate-text'; +import { tool as generateObject } from './generate-object'; +import { tool as generateImage } from './generate-image'; +import { tool as generateSpeech } from './generate-speech'; +import { tool as generateVideo } from './generate-video'; export const toolsByCategory: ToolCategory[] = [ { @@ -221,7 +226,19 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'AI', - components: [tokenCounter, promptOptimizer, jsonSchemaGenerator, regexGenerator, sqlQueryGenerator, aiChat], + components: [ + tokenCounter, + promptOptimizer, + jsonSchemaGenerator, + regexGenerator, + sqlQueryGenerator, + aiChat, + generateText, + generateObject, + generateImage, + generateSpeech, + generateVideo, + ], }, ]; diff --git a/src/utils/ai-gateway.ts b/src/utils/ai-gateway.ts new file mode 100644 index 00000000..1213af0d --- /dev/null +++ b/src/utils/ai-gateway.ts @@ -0,0 +1,112 @@ +import { aiGatewayApiKey } from '@/composable/aiGatewayKey'; + +export { aiGatewayApiKey, clearAiGatewayApiKey, hasAiGateway, isAiGatewayKeyFromEnv, setAiGatewayApiKey } from '@/composable/aiGatewayKey'; + +export const aiGatewayBaseUrl = 'https://ai-gateway.vercel.sh/v1'; + +export type AiGatewayModality = 'text' | 'object' | 'image' | 'speech' | 'video'; + +export interface AiGatewayRequest { + modality: AiGatewayModality + model: string + prompt: string + [option: string]: unknown +} + +export interface AiGatewayResult { + /** Whether the result comes from the AI Gateway or from the local demo generator */ + source: 'gateway' | 'demo' + data: Record +} + +/** + * Calls the Vercel AI Gateway for the given modality. + * Throws when no API key is configured or when the gateway returns an error. + */ +export async function callAiGateway(request: AiGatewayRequest): Promise { + const apiKey = aiGatewayApiKey.value; + + if (!apiKey) { + throw new Error('No AI Gateway API key configured'); + } + + const response = await fetch(`${aiGatewayBaseUrl}/${request.modality}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const reason = await response.text().catch(() => ''); + throw new Error(`AI Gateway request failed with status ${response.status}${reason ? `: ${reason}` : ''}`); + } + + return { source: 'gateway', data: await response.json() }; +} + +/** + * Deterministic pseudo random number generator, used by the demo generators so + * that the same prompt always produces the same result. + */ +export function createSeededRandom(seed: string) { + let hash = 2166136261; + for (let index = 0; index < seed.length; index++) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return () => { + hash += 0x6D2B79F5; + let t = hash; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function pickOne(items: T[], random: () => number) { + return items[Math.floor(random() * items.length)]; +} + +export function capitalize(text: string) { + return text.length === 0 ? text : text[0].toUpperCase() + text.slice(1); +} + +/** + * Splits a prompt into meaningful keywords, used as a seed by the demo generators. + */ +export function extractKeywords(prompt: string) { + const stopWords = new Set([ + 'a', + 'an', + 'and', + 'as', + 'at', + 'be', + 'by', + 'for', + 'from', + 'in', + 'into', + 'is', + 'it', + 'of', + 'on', + 'or', + 'that', + 'the', + 'this', + 'to', + 'with', + ]); + + return prompt + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .split(/\s+/) + .filter(word => word.length > 2 && !stopWords.has(word)); +} diff --git a/vite.config.ts b/vite.config.ts index bb0789cb..5768dd07 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,6 +20,9 @@ const baseUrl = process.env.BASE_URL ?? '/'; // https://vitejs.dev/config/ export default defineConfig({ + server: { + allowedHosts: true, + }, plugins: [ VueI18n({ runtimeOnly: true,