Phase 6: KI-Prompt-Generator
Neues Modul js/ai/promptBuilder.js: reine Funktion buildPrompt(), deckt alle 10 Pflichtpunkte aus anforderung.md explizit ab (Region+Radius, Kategorien, Alter, wichtige Eigenschaften, Anzahl Ergebnisse, keine erfundenen Informationen, unsichere Angaben kennzeichnen, ausschliesslich gueltiges JSON, genaue Koordinaten, direkte Importierbarkeit) und bettet das exakte JSON-Zielschema aus anforderung.md unveraendert ein. UI (js/ui/promptGenerator.js): Modal mit allen Eingabefeldern aus der Anforderung (Region, Radius, Kategorien, Altersgruppe, Indoor/Outdoor, Kostenrahmen, max. Ergebnisse, besondere Anforderungen als Checkboxen + Freitext, Ausgabesprache), Live-Vorschau des generierten Prompts, Copy-to-Clipboard mit execCommand-Fallback (js/utils/clipboard.js) und Speichern als Textdatei (js/utils/download.js). Neuer Toolbar-Button oeffnet den Generator. JSON-Import der KI-Antwort ist bewusst nicht Teil dieser Phase, sondern folgt in Phase 7. docs/plan.md entsprechend fortgeschrieben.
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
// KI-Prompt-Generator (Modal), Phase 6 (siehe anforderung.md Abschnitt
|
||||
// "KI-Prompt-Generator"). Sammelt Formularwerte, übergibt sie an die reine
|
||||
// Funktion EventMap.ai.buildPrompt() (js/ai/promptBuilder.js) und zeigt das
|
||||
// Ergebnis live in einem <textarea readonly> an. Bietet zusätzlich Buttons
|
||||
// zum Kopieren (Zwischenablage) und Speichern als Textdatei.
|
||||
//
|
||||
// WICHTIG: Diese Phase implementiert nur die Prompt-Erzeugung. Der spätere
|
||||
// JSON-Import (Einlesen der KI-Antwort zurück in die App) ist NICHT Teil
|
||||
// dieser Datei - das folgt in Phase 7.
|
||||
//
|
||||
// Aufbau/Muster analog zu js/ui/form.js (Modal mit Backdrop, Escape schließt,
|
||||
// Fokus auf erstes Feld beim Öffnen). Rendering ausschließlich über
|
||||
// textContent/DOM-APIs (el()), niemals innerHTML mit Nutzerdaten.
|
||||
|
||||
(function () {
|
||||
const { el, clearChildren } = EventMap.utils;
|
||||
|
||||
const SPECIAL_REQUIREMENTS = [
|
||||
'Kinderwagen geeignet',
|
||||
'Für Kleinkinder geeignet',
|
||||
'Bei schlechtem Wetter geeignet',
|
||||
'Kostenlos',
|
||||
'Mit Tieren',
|
||||
'Mit Spielplatz',
|
||||
'Ruhige Umgebung',
|
||||
'Barrierefrei',
|
||||
'Für einen Tagesausflug geeignet',
|
||||
];
|
||||
|
||||
const ENVIRONMENT_OPTIONS = [
|
||||
{ value: 'both', label: 'Indoor & Outdoor' },
|
||||
{ value: 'indoor', label: 'Nur Indoor' },
|
||||
{ value: 'outdoor', label: 'Nur Outdoor' },
|
||||
];
|
||||
|
||||
const COST_RANGE_OPTIONS = [
|
||||
{ value: 'any', label: 'Beliebig (keine Einschränkung)' },
|
||||
{ value: 'low', label: 'Kostenlos oder günstig' },
|
||||
{ value: 'free', label: 'Nur kostenlos' },
|
||||
];
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'Englisch' },
|
||||
];
|
||||
|
||||
let container = null;
|
||||
let refs = null;
|
||||
|
||||
function section(legendText, children) {
|
||||
return el('fieldset', { className: 'place-form__section' }, [
|
||||
el('legend', { className: 'place-form__section-label', text: legendText }),
|
||||
...children,
|
||||
]);
|
||||
}
|
||||
|
||||
function row(children) {
|
||||
return el('div', { className: 'place-form__row' }, children);
|
||||
}
|
||||
|
||||
function field(id, labelText, inputNode, { required = false } = {}) {
|
||||
inputNode.id = id;
|
||||
if (required) {
|
||||
inputNode.setAttribute('required', '');
|
||||
inputNode.setAttribute('aria-required', 'true');
|
||||
}
|
||||
|
||||
const labelChildren = [labelText];
|
||||
if (required) {
|
||||
labelChildren.push(
|
||||
el('span', { className: 'form-field__required', attrs: { 'aria-hidden': 'true' } }, [' *'])
|
||||
);
|
||||
}
|
||||
|
||||
return el('div', { className: 'form-field' }, [
|
||||
el('label', { className: 'form-field__label', attrs: { for: id } }, labelChildren),
|
||||
inputNode,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildCategoryGroup() {
|
||||
const { CATEGORIES } = EventMap.config;
|
||||
refs.categoryInputs = [];
|
||||
|
||||
const options = CATEGORIES.map((cat) => {
|
||||
const id = `pg-cat-${cat.id}`;
|
||||
const input = el('input', {
|
||||
attrs: { type: 'checkbox', id, value: cat.id },
|
||||
on: { change: updateOutput },
|
||||
});
|
||||
refs.categoryInputs.push(input);
|
||||
|
||||
return el('label', { className: 'filter-panel__category-option', attrs: { for: id } }, [
|
||||
input,
|
||||
el('span', {
|
||||
className: 'filter-panel__category-swatch',
|
||||
attrs: { 'aria-hidden': 'true' },
|
||||
style: { backgroundColor: cat.color },
|
||||
}),
|
||||
`${cat.icon} ${cat.label}`,
|
||||
]);
|
||||
});
|
||||
|
||||
return el('div', { className: 'filter-panel__categories' }, options);
|
||||
}
|
||||
|
||||
function buildSpecialRequirementsGroup() {
|
||||
refs.specialRequirementInputs = SPECIAL_REQUIREMENTS.map((label, index) => {
|
||||
const id = `pg-requirement-${index}`;
|
||||
const input = el('input', {
|
||||
attrs: { type: 'checkbox', id, value: label },
|
||||
on: { change: updateOutput },
|
||||
});
|
||||
return { input, label };
|
||||
});
|
||||
|
||||
const options = refs.specialRequirementInputs.map(({ input, label }) =>
|
||||
el('label', { className: 'form-checkbox', attrs: { for: input.id } }, [input, label])
|
||||
);
|
||||
|
||||
return el('div', { className: 'place-form__checkbox-group' }, options);
|
||||
}
|
||||
|
||||
// Die eigentliche id/for-Verdrahtung übernimmt field(); hier nur die
|
||||
// <option>-Liste erzeugen.
|
||||
function buildSelect(options) {
|
||||
return el(
|
||||
'select',
|
||||
{ on: { change: updateOutput } },
|
||||
options.map((opt) => el('option', { attrs: { value: opt.value }, text: opt.label }))
|
||||
);
|
||||
}
|
||||
|
||||
function buildSkeleton(rootContainer) {
|
||||
refs = {};
|
||||
|
||||
refs.backdrop = el('div', {
|
||||
className: 'prompt-generator-modal__backdrop',
|
||||
on: { click: () => closePromptGenerator() },
|
||||
});
|
||||
|
||||
refs.closeBtn = el(
|
||||
'button',
|
||||
{
|
||||
className: 'prompt-generator-modal__close',
|
||||
attrs: { type: 'button', 'aria-label': 'KI-Prompt-Generator schließen' },
|
||||
on: { click: () => closePromptGenerator() },
|
||||
},
|
||||
['✕']
|
||||
);
|
||||
|
||||
refs.title = el('h2', {
|
||||
className: 'prompt-generator__title',
|
||||
attrs: { id: 'prompt-generator-title' },
|
||||
text: '🤖 Neue Ausflugsziele mit KI erstellen',
|
||||
});
|
||||
|
||||
refs.intro = el('p', {
|
||||
className: 'prompt-generator__intro',
|
||||
text:
|
||||
'Fülle das Formular aus, um automatisch einen fertigen Prompt für eine externe KI ' +
|
||||
'(z.B. ChatGPT, Claude, Gemini) zu erzeugen. Der Prompt bittet die KI, passende ' +
|
||||
'Ausflugsziele zu recherchieren und als strukturiertes JSON zurückzugeben.',
|
||||
});
|
||||
|
||||
// -- Eingabefelder --
|
||||
|
||||
refs.region = el('input', {
|
||||
attrs: { type: 'text', placeholder: 'z.B. München, Chiemgau, Bayern …' },
|
||||
on: { input: updateOutput },
|
||||
});
|
||||
|
||||
refs.locationHint = el('p', { className: 'filter-panel__hint' });
|
||||
refs.locationHint.hidden = true;
|
||||
refs.useLocationBtn = el(
|
||||
'button',
|
||||
{
|
||||
className: 'btn btn--secondary prompt-generator__location-btn',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: handleUseLocationClick },
|
||||
},
|
||||
['Standort als Suchgebiet übernehmen']
|
||||
);
|
||||
refs.useLocationBtn.hidden = true;
|
||||
|
||||
refs.radius = el('input', {
|
||||
attrs: { type: 'number', min: '1', step: '1', placeholder: 'z.B. 30' },
|
||||
on: { input: updateOutput },
|
||||
});
|
||||
|
||||
refs.ageMin = el('input', {
|
||||
attrs: { type: 'number', min: '0', step: '1', placeholder: 'ab' },
|
||||
on: { input: updateOutput },
|
||||
});
|
||||
refs.ageMax = el('input', {
|
||||
attrs: { type: 'number', min: '0', step: '1', placeholder: 'bis' },
|
||||
on: { input: updateOutput },
|
||||
});
|
||||
|
||||
refs.environment = buildSelect(ENVIRONMENT_OPTIONS);
|
||||
refs.costRange = buildSelect(COST_RANGE_OPTIONS);
|
||||
refs.costDetails = el('input', {
|
||||
attrs: { type: 'text', placeholder: 'z.B. maximal 15 € Eintritt pro Familie (optional)' },
|
||||
on: { input: updateOutput },
|
||||
});
|
||||
|
||||
refs.maxResults = el('input', {
|
||||
attrs: { type: 'number', min: '1', step: '1', value: '10' },
|
||||
on: { input: updateOutput },
|
||||
});
|
||||
|
||||
refs.additionalRequirements = el('textarea', {
|
||||
attrs: { placeholder: 'Weitere, oben nicht aufgeführte Anforderungen (optional)' },
|
||||
on: { input: updateOutput },
|
||||
});
|
||||
|
||||
refs.outputLanguage = buildSelect(LANGUAGE_OPTIONS);
|
||||
|
||||
const form = el('div', { className: 'place-form prompt-generator__form' }, [
|
||||
section('Suchgebiet', [
|
||||
field('pg-region', 'Region, Ort oder Suchgebiet', refs.region, { required: true }),
|
||||
refs.locationHint,
|
||||
refs.useLocationBtn,
|
||||
field('pg-radius', 'Maximaler Radius (km)', refs.radius),
|
||||
]),
|
||||
section('Kategorien', [buildCategoryGroup()]),
|
||||
section('Altersgruppe der Kinder', [
|
||||
row([
|
||||
field('pg-age-min', 'Alter ab (Jahre)', refs.ageMin),
|
||||
field('pg-age-max', 'Alter bis (Jahre)', refs.ageMax),
|
||||
]),
|
||||
]),
|
||||
section('Umgebung & Kosten', [
|
||||
field('pg-environment-field', 'Indoor, Outdoor oder beides', refs.environment),
|
||||
field('pg-cost-range-field', 'Kostenrahmen', refs.costRange),
|
||||
field('pg-cost-details-field', 'Kostenrahmen – zusätzliche Angabe', refs.costDetails),
|
||||
]),
|
||||
section('Ergebnisse', [field('pg-max-results-field', 'Maximale Anzahl an Ergebnissen', refs.maxResults)]),
|
||||
section('Besondere Anforderungen', [
|
||||
buildSpecialRequirementsGroup(),
|
||||
field('pg-additional-requirements-field', 'Zusätzliche Anforderungen (Freitext)', refs.additionalRequirements),
|
||||
]),
|
||||
section('Sprache der Ausgabe', [
|
||||
field('pg-output-language-field', 'Sprache der KI-Ergebnistexte', refs.outputLanguage),
|
||||
]),
|
||||
]);
|
||||
|
||||
// -- Ausgabebereich --
|
||||
|
||||
refs.output = el('textarea', {
|
||||
className: 'prompt-generator__output-textarea',
|
||||
attrs: { readonly: '', id: 'prompt-generator-output', rows: '16' },
|
||||
});
|
||||
|
||||
refs.copyBtn = el(
|
||||
'button',
|
||||
{ className: 'btn btn--primary', attrs: { type: 'button' }, on: { click: handleCopyClick } },
|
||||
['In Zwischenablage kopieren']
|
||||
);
|
||||
refs.downloadBtn = el(
|
||||
'button',
|
||||
{ className: 'btn btn--secondary', attrs: { type: 'button' }, on: { click: handleDownloadClick } },
|
||||
['Als Textdatei speichern']
|
||||
);
|
||||
refs.copyStatus = el('p', { className: 'prompt-generator__status', attrs: { role: 'status' } });
|
||||
|
||||
const outputSection = el('div', { className: 'prompt-generator__output' }, [
|
||||
el('h3', { className: 'place-form__section-label', text: 'Generierter Prompt' }),
|
||||
refs.output,
|
||||
el('div', { className: 'prompt-generator__output-actions' }, [refs.copyBtn, refs.downloadBtn, refs.copyStatus]),
|
||||
el('div', { className: 'prompt-generator__instructions' }, [
|
||||
el('h3', { className: 'place-form__section-label', text: 'Anleitung' }),
|
||||
el('ol', { className: 'prompt-generator__instructions-list' }, [
|
||||
el('li', { text: 'Formular ausfüllen.' }),
|
||||
el('li', { text: 'Prompt kopieren oder als Textdatei speichern.' }),
|
||||
el('li', {
|
||||
text: 'In eine KI (z.B. ChatGPT, Claude, Gemini) einfügen und die Antwort abwarten.',
|
||||
}),
|
||||
el('li', {
|
||||
text:
|
||||
'Die JSON-Antwort der KI im nächsten Schritt (KI-JSON-Import, in einer späteren Version ' +
|
||||
'dieser Anwendung) prüfen und importieren.',
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const layout = el('div', { className: 'prompt-generator__layout' }, [form, outputSection]);
|
||||
|
||||
refs.panel = el(
|
||||
'div',
|
||||
{
|
||||
className: 'prompt-generator-modal__panel',
|
||||
attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'prompt-generator-title' },
|
||||
},
|
||||
[refs.closeBtn, refs.title, refs.intro, layout]
|
||||
);
|
||||
|
||||
rootContainer.appendChild(refs.backdrop);
|
||||
rootContainer.appendChild(refs.panel);
|
||||
}
|
||||
|
||||
function readCheckedValues(inputs) {
|
||||
return inputs.filter((input) => input.checked).map((input) => input.value);
|
||||
}
|
||||
|
||||
function parseOptionalNumber(raw) {
|
||||
const trimmed = (raw || '').trim();
|
||||
if (trimmed === '') return null;
|
||||
const num = Number(trimmed);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function readFormValues() {
|
||||
const { CATEGORIES } = EventMap.config;
|
||||
const selectedCategoryIds = readCheckedValues(refs.categoryInputs);
|
||||
const categoryLabels = CATEGORIES.filter((cat) => selectedCategoryIds.includes(cat.id)).map(
|
||||
(cat) => cat.label
|
||||
);
|
||||
|
||||
return {
|
||||
region: refs.region.value,
|
||||
radiusKm: parseOptionalNumber(refs.radius.value),
|
||||
categoryLabels,
|
||||
ageMin: parseOptionalNumber(refs.ageMin.value),
|
||||
ageMax: parseOptionalNumber(refs.ageMax.value),
|
||||
environment: refs.environment.value,
|
||||
costRange: refs.costRange.value,
|
||||
costDetails: refs.costDetails.value,
|
||||
maxResults: parseOptionalNumber(refs.maxResults.value) || 10,
|
||||
specialRequirements: readCheckedValues(refs.specialRequirementInputs.map((r) => r.input)),
|
||||
additionalRequirements: refs.additionalRequirements.value,
|
||||
outputLanguage: refs.outputLanguage.value,
|
||||
};
|
||||
}
|
||||
|
||||
/** Berechnet den Prompt live neu und zeigt ihn im Ausgabefeld an. Ohne gültige Region wird stattdessen ein Hinweis angezeigt. */
|
||||
function updateOutput() {
|
||||
const values = readFormValues();
|
||||
const hasRegion = (values.region || '').trim() !== '';
|
||||
|
||||
if (!hasRegion) {
|
||||
refs.output.value = '(Bitte zunächst eine Region, einen Ort oder ein Suchgebiet angeben, um den Prompt zu erzeugen.)';
|
||||
refs.copyBtn.disabled = true;
|
||||
refs.downloadBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
refs.output.value = EventMap.ai.buildPrompt(values);
|
||||
refs.copyBtn.disabled = false;
|
||||
refs.downloadBtn.disabled = false;
|
||||
}
|
||||
|
||||
function resetCopyStatus() {
|
||||
refs.copyStatus.textContent = '';
|
||||
}
|
||||
|
||||
async function handleCopyClick() {
|
||||
const success = await EventMap.utils.copyToClipboard(refs.output.value);
|
||||
const defaultLabel = 'In Zwischenablage kopieren';
|
||||
|
||||
if (success) {
|
||||
refs.copyBtn.textContent = 'Kopiert!';
|
||||
refs.copyStatus.textContent = '';
|
||||
} else {
|
||||
refs.copyBtn.textContent = defaultLabel;
|
||||
refs.copyStatus.textContent =
|
||||
'Kopieren war nicht möglich. Bitte den Text im Feld oben manuell markieren und kopieren.';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
refs.copyBtn.textContent = defaultLabel;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function handleDownloadClick() {
|
||||
EventMap.utils.downloadTextFile('eventmap-ki-prompt.txt', refs.output.value);
|
||||
}
|
||||
|
||||
function handleUseLocationClick() {
|
||||
const { userLocation } = EventMap.store.getState();
|
||||
if (!userLocation) return;
|
||||
|
||||
refs.region.value = `Umkreis um ${userLocation.lat.toFixed(4)}, ${userLocation.lng.toFixed(4)}`;
|
||||
updateOutput();
|
||||
refs.region.focus();
|
||||
}
|
||||
|
||||
function syncLocationHint() {
|
||||
const { userLocation } = EventMap.store.getState();
|
||||
const available = Boolean(userLocation);
|
||||
refs.locationHint.hidden = !available;
|
||||
refs.useLocationBtn.hidden = !available;
|
||||
if (available) {
|
||||
refs.locationHint.textContent = `Dein aktueller Standort ist bekannt (${userLocation.lat.toFixed(4)}, ${userLocation.lng.toFixed(4)}).`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Öffnet den KI-Prompt-Generator. */
|
||||
function openPromptGenerator() {
|
||||
if (!container || !refs) {
|
||||
console.error('EventMap: Prompt-Generator wurde noch nicht initialisiert (initPromptGenerator()).');
|
||||
return;
|
||||
}
|
||||
|
||||
syncLocationHint();
|
||||
resetCopyStatus();
|
||||
updateOutput();
|
||||
|
||||
container.hidden = false;
|
||||
document.body.classList.add('prompt-generator-modal-open');
|
||||
refs.region.focus();
|
||||
}
|
||||
|
||||
function closePromptGenerator() {
|
||||
if (!container || container.hidden) return;
|
||||
container.hidden = true;
|
||||
document.body.classList.remove('prompt-generator-modal-open');
|
||||
}
|
||||
|
||||
/** Initialisiert das Prompt-Generator-Modal im Element mit der übergebenen id. */
|
||||
function initPromptGenerator(containerId) {
|
||||
const containerEl = document.getElementById(containerId);
|
||||
if (!containerEl) {
|
||||
console.error(`EventMap: Prompt-Generator-Container #${containerId} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
container = containerEl;
|
||||
clearChildren(container);
|
||||
buildSkeleton(container);
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !container.hidden) {
|
||||
closePromptGenerator();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
EventMap.ui.initPromptGenerator = initPromptGenerator;
|
||||
EventMap.ui.openPromptGenerator = openPromptGenerator;
|
||||
})();
|
||||
Reference in New Issue
Block a user