diff --git a/messages/de.json b/messages/de.json index 84cfaa376..11f2ddc36 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7186,7 +7186,9 @@ "deleteDialog": { "title": "Kategorie löschen", "message": "Kategorie wirklich löschen?" - } + }, + "aiFillPlaceholder": "z.B. «Reparatur-Tipps: Anleitungen zum Selberflicken, in Grün»", + "aiRefinePlaceholder": "z.B. «Beschreibung kürzer, Farbe eher blau»" }, "dashboard": { "myTasks": "Meine Aufgaben", @@ -9135,5 +9137,15 @@ "body": "Diese Statuten wurden an der Gründungsversammlung beschlossen und sind seither in Kraft." } } + }, + "aiForm": { + "fillTitle": "Mit KI ausfüllen", + "refineTitle": "Mit KI ändern", + "fillHint": "Beschreiben Sie es in eigenen Worten — das Formular füllt sich aus.", + "refineHint": "Sagen Sie, was geändert werden soll — z.B. «kürzer» oder «eher grün».", + "fillSubmit": "Felder ausfüllen", + "refineSubmit": "Änderung übernehmen", + "undo": "Rückgängig", + "changedFields": "{count, plural, one {# Feld geändert} other {# Felder geändert}}" } } diff --git a/messages/en.json b/messages/en.json index 30039b97e..2fa1fb30f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7102,7 +7102,9 @@ "deleteDialog": { "title": "Delete category", "message": "Really delete this category?" - } + }, + "aiFillPlaceholder": "e.g. «Repair tips: guides for fixing things yourself, in green»", + "aiRefinePlaceholder": "e.g. «shorter description, make it blue»" }, "dashboard": { "myTasks": "My tasks", @@ -9118,5 +9120,15 @@ "body": "These statutes were adopted at the founding assembly and have been in force since." } } + }, + "aiForm": { + "fillTitle": "Fill with AI", + "refineTitle": "Change with AI", + "fillHint": "Describe it in your own words and the form fills in.", + "refineHint": "Say what to change — e.g. «shorter» or «make it green».", + "fillSubmit": "Fill fields", + "refineSubmit": "Apply change", + "undo": "Undo", + "changedFields": "{count, plural, one {# field changed} other {# fields changed}}" } } diff --git a/messages/es.json b/messages/es.json index 953a6e8bf..f8f41149d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7053,7 +7053,9 @@ "deleteDialog": { "title": "Eliminar categoría", "message": "¿Seguro que desea eliminar la categoría?" - } + }, + "aiFillPlaceholder": "p.ej. «Consejos de reparación: guías para arreglar tú mismo, en verde»", + "aiRefinePlaceholder": "p.ej. «descripción más corta, mejor azul»" }, "dashboard": { "myTasks": "Mis tareas", @@ -9013,5 +9015,15 @@ "body": "Estos estatutos fueron aprobados en la asamblea constitutiva y están en vigor desde entonces." } } + }, + "aiForm": { + "fillTitle": "Rellenar con IA", + "refineTitle": "Modificar con IA", + "fillHint": "Descríbelo con tus palabras y el formulario se rellena.", + "refineHint": "Di qué cambiar — p.ej. «más corto» o «mejor en verde».", + "fillSubmit": "Rellenar campos", + "refineSubmit": "Aplicar cambio", + "undo": "Deshacer", + "changedFields": "{count, plural, one {# campo modificado} other {# campos modificados}}" } } diff --git a/messages/fr.json b/messages/fr.json index 6bf7c9075..d9b9e30b2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7053,7 +7053,9 @@ "deleteDialog": { "title": "Supprimer la catégorie", "message": "Voulez-vous vraiment supprimer la catégorie ?" - } + }, + "aiFillPlaceholder": "p.ex. «Conseils de réparation : guides pour réparer soi-même, en vert»", + "aiRefinePlaceholder": "p.ex. «description plus courte, plutôt bleu»" }, "dashboard": { "myTasks": "Mes tâches", @@ -9015,5 +9017,15 @@ "body": "Les présents statuts ont été adoptés lors de l'assemblée constitutive et sont en vigueur depuis lors." } } + }, + "aiForm": { + "fillTitle": "Remplir avec l'IA", + "refineTitle": "Modifier avec l'IA", + "fillHint": "Décrivez-le avec vos mots — le formulaire se remplit.", + "refineHint": "Dites ce qu'il faut changer — p.ex. «plus court» ou «plutôt vert».", + "fillSubmit": "Remplir les champs", + "refineSubmit": "Appliquer la modification", + "undo": "Annuler", + "changedFields": "{count, plural, one {# champ modifié} other {# champs modifiés}}" } } diff --git a/messages/it.json b/messages/it.json index 23522ed26..0f4ddb02d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7053,7 +7053,9 @@ "deleteDialog": { "title": "Elimina categoria", "message": "Eliminare davvero la categoria?" - } + }, + "aiFillPlaceholder": "p.es. «Consigli di riparazione: guide per riparare da sé, in verde»", + "aiRefinePlaceholder": "p.es. «descrizione più corta, piuttosto blu»" }, "dashboard": { "myTasks": "I miei compiti", @@ -9013,5 +9015,15 @@ "body": "Il presente statuto è stato approvato dall'assemblea costitutiva ed è da allora in vigore." } } + }, + "aiForm": { + "fillTitle": "Compila con l'IA", + "refineTitle": "Modifica con l'IA", + "fillHint": "Descrivilo con parole tue — il modulo si compila.", + "refineHint": "Di' cosa cambiare — p.es. «più corto» o «piuttosto verde».", + "fillSubmit": "Compila i campi", + "refineSubmit": "Applica la modifica", + "undo": "Annulla", + "changedFields": "{count, plural, one {# campo modificato} other {# campi modificati}}" } } diff --git a/messages/ja.json b/messages/ja.json index 7dff620fc..950416be1 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -7053,7 +7053,9 @@ "deleteDialog": { "title": "カテゴリーを削除", "message": "本当にこのカテゴリーを削除しますか?" - } + }, + "aiFillPlaceholder": "例:「修理のヒント:自分で直すためのガイド、緑で」", + "aiRefinePlaceholder": "例:「説明を短く、青にして」" }, "dashboard": { "myTasks": "マイタスク", @@ -9013,5 +9015,15 @@ "body": "本定款は設立総会において議決され、以後効力を有する。" } } + }, + "aiForm": { + "fillTitle": "AIで入力", + "refineTitle": "AIで変更", + "fillHint": "自分の言葉で説明すると、フォームが入力されます。", + "refineHint": "変更内容を伝えてください — 例:「短く」「緑に」。", + "fillSubmit": "項目を入力", + "refineSubmit": "変更を適用", + "undo": "元に戻す", + "changedFields": "{count, plural, other {# 件の項目を変更しました}}" } } diff --git a/messages/ko.json b/messages/ko.json index 18b4b9f0c..652acf990 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7053,7 +7053,9 @@ "deleteDialog": { "title": "카테고리 삭제", "message": "정말 카테고리를 삭제하시겠습니까?" - } + }, + "aiFillPlaceholder": "예: 「수리 팁: 직접 고치는 가이드, 초록색으로」", + "aiRefinePlaceholder": "예: 「설명을 더 짧게, 파란색으로」" }, "dashboard": { "myTasks": "내 업무", @@ -9013,5 +9015,15 @@ "body": "본 정관은 창립총회에서 의결되었으며 그 이후 효력을 가진다." } } + }, + "aiForm": { + "fillTitle": "AI로 채우기", + "refineTitle": "AI로 수정", + "fillHint": "직접 설명하면 양식이 채워집니다.", + "refineHint": "무엇을 바꿀지 말해 주세요 — 예: 「더 짧게」, 「초록색으로」.", + "fillSubmit": "항목 채우기", + "refineSubmit": "변경 적용", + "undo": "실행 취소", + "changedFields": "{count, plural, other {# 개 항목이 변경되었습니다}}" } } diff --git a/messages/ru.json b/messages/ru.json index 30cb30891..d60893cce 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -7047,7 +7047,9 @@ "deleteDialog": { "title": "Удалить категорию", "message": "Действительно удалить категорию?" - } + }, + "aiFillPlaceholder": "напр. «Советы по ремонту: инструкции для самостоятельного ремонта, зелёным»", + "aiRefinePlaceholder": "напр. «описание короче, лучше синий»" }, "dashboard": { "myTasks": "Мои задачи", @@ -9007,5 +9009,15 @@ "body": "Настоящий устав был принят на учредительном собрании и с тех пор действует." } } + }, + "aiForm": { + "fillTitle": "Заполнить с ИИ", + "refineTitle": "Изменить с ИИ", + "fillHint": "Опишите своими словами — форма заполнится.", + "refineHint": "Скажите, что изменить — напр. «короче» или «сделать зелёным».", + "fillSubmit": "Заполнить поля", + "refineSubmit": "Применить изменение", + "undo": "Отменить", + "changedFields": "{count, plural, one {# поле изменено} few {# поля изменено} other {# полей изменено}}" } } diff --git a/package-lock.json b/package-lock.json index 18edbdfbe..310f4b400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/client-s3": "^3.1070.0", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", + "@fleet/ai-forms": "github:maonakamoto/ai-forms#v0.1.0", "@sentry/nextjs": "^10.61.0", "@tiptap/extension-link": "^3.29.2", "@tiptap/extension-table": "^3.29.2", @@ -1951,6 +1952,19 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, + "node_modules/@fleet/ai-forms": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/maonakamoto/ai-forms.git#68087895ab9d46d73d03b91ebf5e648b969ebd76", + "license": "MIT", + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", diff --git a/package.json b/package.json index ded03649c..842ee93e9 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@aws-sdk/client-s3": "^3.1070.0", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", + "@fleet/ai-forms": "github:maonakamoto/ai-forms#v0.1.0", "@sentry/nextjs": "^10.61.0", "@tiptap/extension-link": "^3.29.2", "@tiptap/extension-table": "^3.29.2", diff --git a/src/app/api/ai/form-assist/route.ts b/src/app/api/ai/form-assist/route.ts new file mode 100644 index 000000000..841248a25 --- /dev/null +++ b/src/app/api/ai/form-assist/route.ts @@ -0,0 +1,51 @@ +/** + * Form Assist API + * + * POST /api/ai/form-assist + * + * One route for every AI-assisted form in the app. Adding assistance to a new + * form means adding it to AI_FORMS — nothing here changes. + * + * The field registry stays server-side on purpose: the client names a form, + * never the fields, so it cannot widen what the model is allowed to write. + * + * Auth required — staff only, same as the other advisors. + */ + +import { createFormAssistHandler } from '@fleet/ai-forms/server' +import { auth } from '@/auth' +import { callWithFallback } from '@/lib/ai/providers' +import { AI_FORMS } from '@/config/ai-forms' + +export const POST = createFormAssistHandler({ + targets: AI_FORMS, + + authorize: async () => { + const session = await auth() + return session?.user?.email + ? { ok: true } + : { ok: false, status: 401, error: 'Nicht angemeldet.' } + }, + + // Reuses the app's own provider chain, so form assistance inherits the + // existing key handling, fallback order and timeouts rather than adding a + // second policy to keep in sync. + complete: async ({ system, prompt, maxTokens, temperature }) => { + const result = await callWithFallback({ + systemPrompt: system, + userPrompt: prompt, + maxTokens, + temperature, + }) + if (!result) { + throw new Error('Kein KI-Anbieter verfügbar.') + } + return result.text + }, + + // Deliberately NOT wrapped in the house { success, data } envelope. This + // route has exactly one client — useAiForm — and the hook reads the + // AssistResult shape directly (`result.ok`, `result.values`). Wrapping it + // typechecks fine and breaks at runtime, because the hook would see an + // object with no `ok` and report every successful call as a failure. +}) diff --git a/src/components/admin/CategoryForm.tsx b/src/components/admin/CategoryForm.tsx index bf90ba5ee..e18d5a1a5 100644 --- a/src/components/admin/CategoryForm.tsx +++ b/src/components/admin/CategoryForm.tsx @@ -7,6 +7,7 @@ */ import { useState } from 'react' +import { useAiForm } from '@fleet/ai-forms/react' import { useRouter } from 'next/navigation' import Heading from '@/components/admin/AdminHeading' import { Tag, Save, ArrowLeft, Trash2 } from 'lucide-react' @@ -24,6 +25,8 @@ import { generateSlug } from '@/lib/utils/slug' import { UI_FEEDBACK_MS } from '@/config/limits' import { ROUTES } from '@/config/routes' import { adminInteractive } from '@/lib/admin-ui' +import { AiFormBar } from '@/components/ui/AiFormBar' +import { CATEGORY_FORM } from '@/config/ai-forms' interface CategoryFormProps { initialData?: Partial @@ -40,23 +43,38 @@ export default function CategoryForm({ const t = useTranslations('admin.categories') const { saving, deleting, error, success, saveCategory, deleteCategory } = useBlogCategories() const [confirmDelete, setConfirmDelete] = useState(false) - const [formData, setFormData] = useState({ - name: initialData?.name || '', - slug: initialData?.slug || '', - description: initialData?.description || '', - color: initialData?.color || DEFAULT_CATEGORY_COLOR, - sort_order: initialData?.sort_order || 0, - is_active: initialData?.is_active ?? true, - }) const [slugManuallyEdited, setSlugManuallyEdited] = useState(isEdit) + // One store, written by both the user and the assistant — that is what lets a + // follow-up instruction ("Beschreibung kürzer") revise what is already here + // instead of starting over. It replaces the per-form useState object. + const assist = useAiForm({ + target: CATEGORY_FORM.key, + fields: CATEGORY_FORM.fields, + initialValues: { + name: initialData?.name || '', + slug: initialData?.slug || '', + description: initialData?.description || '', + color: initialData?.color || DEFAULT_CATEGORY_COLOR, + sort_order: initialData?.sort_order || 0, + is_active: initialData?.is_active ?? true, + }, + // The slug is aiExcluded, so the model never writes it — but a name it + // writes still has to produce one, exactly as typing a name does. Without + // this, an AI-filled form would save with an empty slug. + onApplied: (values, changed) => { + if (slugManuallyEdited || !changed.includes('name')) return + const name = String(values.name ?? '') + if (name) assist.setValue('slug', generateSlug(name)) + }, + }) + + const formData = assist.values as unknown as CategoryFormData + // Auto-generate slug from name — computed during name change, not via effect const updateName = (name: string) => { - setFormData((prev) => ({ - ...prev, - name, - ...(!slugManuallyEdited && name ? { slug: generateSlug(name) } : {}), - })) + assist.setValue('name', name) + if (!slugManuallyEdited && name) assist.setValue('slug', generateSlug(name)) } const handleSubmit = async (e: React.FormEvent) => { @@ -141,6 +159,14 @@ export default function CategoryForm({ {/* Form */}
+ {/* Shown when editing too — refining an existing category is the half a + fill-only button cannot do. */} + +
{/* Name */} @@ -166,7 +192,7 @@ export default function CategoryForm({ value={formData.slug} onChange={(e) => { setSlugManuallyEdited(true) - setFormData((prev) => ({ ...prev, slug: e.target.value })) + assist.setValue('slug', e.target.value) }} placeholder="z.B. nachhaltigkeit" className="font-mono text-sm" @@ -182,10 +208,7 @@ export default function CategoryForm({