|
- package handlers
-
- import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "time"
- "unicode"
-
- "github.com/go-chi/chi/v5"
-
- "qctextbuilder/internal/buildsvc"
- "qctextbuilder/internal/config"
- "qctextbuilder/internal/domain"
- "qctextbuilder/internal/draftsvc"
- "qctextbuilder/internal/mapping"
- "qctextbuilder/internal/onboarding"
- "qctextbuilder/internal/store"
- "qctextbuilder/internal/templatesvc"
- )
-
- type UI struct {
- templateSvc *templatesvc.Service
- onboardSvc *onboarding.Service
- draftSvc *draftsvc.Service
- buildSvc buildsvc.Service
- settings store.SettingsStore
- suggestionGenerator mapping.SuggestionGenerator
- cfg config.Config
- render htmlRenderer
- }
-
- type htmlRenderer interface {
- Render(w http.ResponseWriter, name string, data any)
- }
-
- type pageData struct {
- Title string
- Msg string
- Err string
- Current string
- }
-
- type homePageData struct {
- pageData
- TemplateCount int
- }
-
- type settingsPageData struct {
- pageData
- QCBaseURL string
- PollIntervalSeconds int
- PollTimeoutSeconds int
- PollMaxConcurrent int
- TokenConfigured bool
- LanguageOutputMode string
- MasterPrompt string
- PromptBlocks []domain.PromptBlockConfig
- }
-
- type templatesPageData struct {
- pageData
- Templates []domain.Template
- }
-
- type templateFieldView struct {
- Path string
- FieldKind string
- IsEnabled bool
- IsRequiredByUs bool
- DisplayLabel string
- DisplayOrder int
- WebsiteSection string
- Notes string
- SampleValue string
- }
-
- type websiteSectionOptionView struct {
- Value string
- Label string
- }
-
- type templateDetailPageData struct {
- pageData
- Detail *templatesvc.TemplateDetail
- Fields []templateFieldView
- WebsiteSectionOptions []websiteSectionOptionView
- }
-
- type buildFieldView struct {
- Index int
- AnchorID string
- Path string
- DisplayLabel string
- SampleValue string
- Value string
- SuggestedValue string
- SuggestionReason string
- SuggestionStatus string
- SuggestionSource string
- }
-
- type buildFieldGroupView struct {
- Title string
- Fields []buildFieldView
- }
-
- type buildFieldSectionView struct {
- Key string
- Title string
- Description string
- EditableGroups []buildFieldGroupView
- EditableFields []buildFieldView
- DisabledFields []buildFieldView
- }
-
- type semanticSlotPreviewView struct {
- Slot string
- Count int
- Examples string
- }
-
- type pendingField struct {
- Field domain.TemplateField
- View buildFieldView
- }
-
- type fieldRole struct {
- Label string
- Order int
- }
-
- var blockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`)
- var looseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`)
-
- var knownBlockAreas = map[string]string{
- "m1710": "Hero / Haupttitel",
- "c7886": "Intro / Einleitung",
- "r4830": "Services",
- "m4178": "Gallery / Medien",
- "c2929": "Ueber uns / About",
- "r4748": "Team",
- "r1508": "Testimonials",
- "c1165": "CTA / Highlight / Banner",
- }
-
- type buildNewPageData struct {
- pageData
- Templates []domain.Template
- Drafts []domain.BuildDraft
- SelectedDraftID string
- SelectedTemplateID int64
- SelectedManifestID string
- FieldSections []buildFieldSectionView
- EditableFields []buildFieldView
- EnabledFields []buildFieldView
- SuggestionStateJSON string
- AutofillFocusID string
- ShowDebug bool
- Form buildFormInput
- SemanticSlots []semanticSlotPreviewView
- }
-
- type buildFormInput struct {
- DraftID string
- DraftSource string
- DraftStatus string
- DraftNotes string
- RequestName string
- CompanyName string
- BusinessType string
- Username string
- Email string
- Phone string
- OrgNumber string
- StartDate string
- Mission string
- DescriptionShort string
- DescriptionLong string
- SiteLanguage string
- AddressLine1 string
- AddressLine2 string
- AddressCity string
- AddressRegion string
- AddressZIP string
- AddressCountry string
- WebsiteURL string
- WebsiteSummary string
- LocaleStyle string
- MarketStyle string
- AddressMode string
- ContentTone string
- PromptInstructions string
- MasterPrompt string
- PromptBlocks []domain.PromptBlockConfig
- }
-
- type buildDetailPageData struct {
- pageData
- Build *domain.SiteBuild
- EffectiveGlobal []byte
- CanPoll bool
- CanFetchEditorURL bool
- AutoRefreshSeconds int
- }
-
- func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, settings store.SettingsStore, suggestionGenerator mapping.SuggestionGenerator, cfg config.Config, render htmlRenderer) *UI {
- return &UI{
- templateSvc: templateSvc,
- onboardSvc: onboardSvc,
- draftSvc: draftSvc,
- buildSvc: buildSvc,
- settings: settings,
- suggestionGenerator: suggestionGenerator,
- cfg: cfg,
- render: render,
- }
- }
-
- func (u *UI) Home(w http.ResponseWriter, r *http.Request) {
- templates, err := u.templateSvc.ListTemplates(r.Context())
- if err != nil {
- u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0})
- return
- }
- u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)})
- }
-
- func (u *UI) Settings(w http.ResponseWriter, r *http.Request) {
- settings := u.loadPromptSettings(r.Context())
- u.render.Render(w, "settings", settingsPageData{
- pageData: basePageData(r, "Settings", "/settings"),
- QCBaseURL: u.cfg.QCBaseURL,
- PollIntervalSeconds: u.cfg.PollIntervalSeconds,
- PollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
- PollMaxConcurrent: u.cfg.PollMaxConcurrent,
- TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "",
- LanguageOutputMode: "EN",
- MasterPrompt: settings.MasterPrompt,
- PromptBlocks: settings.PromptBlocks,
- })
- }
-
- func (u *UI) SavePromptSettings(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther)
- return
- }
- settings := u.loadPromptSettings(r.Context())
- settings.MasterPrompt = domain.NormalizeMasterPrompt(r.FormValue("master_prompt"))
- settings.PromptBlocks = parsePromptBlocksFromRequest(r)
- if err := u.settings.UpsertSettings(r.Context(), settings); err != nil {
- http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther)
- return
- }
- http.Redirect(w, r, "/settings?msg=prompt+settings+saved", http.StatusSeeOther)
- }
-
- func (u *UI) Templates(w http.ResponseWriter, r *http.Request) {
- templates, err := u.templateSvc.ListTemplates(r.Context())
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates})
- }
-
- func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) {
- if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil {
- http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther)
- return
- }
- http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther)
- }
-
- func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) {
- templateID, ok := parseTemplateID(w, r)
- if !ok {
- return
- }
- detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
- if err != nil {
- http.Error(w, err.Error(), http.StatusNotFound)
- return
- }
- fields := make([]templateFieldView, 0, len(detail.Fields))
- for _, f := range detail.Fields {
- fields = append(fields, templateFieldView{
- Path: f.Path,
- FieldKind: f.FieldKind,
- IsEnabled: f.IsEnabled,
- IsRequiredByUs: f.IsRequiredByUs,
- DisplayLabel: f.DisplayLabel,
- DisplayOrder: f.DisplayOrder,
- WebsiteSection: domain.NormalizeWebsiteSection(f.WebsiteSection),
- Notes: f.Notes,
- SampleValue: f.SampleValue,
- })
- }
- u.render.Render(w, "template_detail", templateDetailPageData{
- pageData: basePageData(r, "Template Detail", "/templates"),
- Detail: detail,
- Fields: fields,
- WebsiteSectionOptions: websiteSectionOptions(),
- })
- }
-
- func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) {
- templateID, ok := parseTemplateID(w, r)
- if !ok {
- return
- }
- if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil {
- http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
- return
- }
- http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther)
- }
-
- func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
- templateID, ok := parseTemplateID(w, r)
- if !ok {
- return
- }
- if err := r.ParseForm(); err != nil {
- http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther)
- return
- }
-
- count, _ := strconv.Atoi(r.FormValue("field_count"))
- patches := make([]onboarding.FieldPatch, 0, count)
- for i := 0; i < count; i++ {
- path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
- if path == "" {
- continue
- }
- enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on"
- required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on"
- label := r.FormValue(fmt.Sprintf("field_label_%d", i))
- notes := r.FormValue(fmt.Sprintf("field_notes_%d", i))
- websiteSection := r.FormValue(fmt.Sprintf("field_website_section_%d", i))
- order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i))))
- if err != nil {
- http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther)
- return
- }
- patches = append(patches, onboarding.FieldPatch{
- Path: path,
- IsEnabled: boolPtr(enabled),
- IsRequiredByUs: boolPtr(required),
- DisplayLabel: strPtr(label),
- DisplayOrder: intPtr(order),
- WebsiteSection: strPtr(websiteSection),
- Notes: strPtr(notes),
- })
- }
-
- manifestID := r.FormValue("manifest_id")
- if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil {
- http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
- return
- }
- http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther)
- }
-
- func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) {
- settings := u.loadPromptSettings(r.Context())
- selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64)
- selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id"))
- form := buildFormInput{
- DraftID: selectedDraftID,
- DraftSource: "ui",
- DraftStatus: "draft",
- MasterPrompt: settings.MasterPrompt,
- PromptBlocks: clonePromptBlocks(settings.PromptBlocks),
- }
- fieldValues := map[string]string{}
- suggestionState := domain.DraftSuggestionState{}
- if selectedDraftID != "" {
- draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID)
- if err == nil {
- selectedTemplateID = draft.TemplateID
- form = buildFormInputFromDraft(draft)
- fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON)
- suggestionState = parseSuggestionStateJSON(draft.SuggestionStateJSON)
- form.MasterPrompt = settings.MasterPrompt
- form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
- }
- }
- form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
- data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues, suggestionState)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- u.render.Render(w, "build_new", data)
- }
-
- func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
- return
- }
-
- form := buildFormInputFromRequest(r)
- form = u.applyPromptConfigForBuildFlow(r.Context(), form)
- fieldValues := parseBuildFieldValues(r)
- suggestionState := parseSuggestionStateFromRequest(r)
- globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
- CompanyName: form.CompanyName,
- BusinessType: form.BusinessType,
- Username: form.Username,
- Email: form.Email,
- Phone: form.Phone,
- OrgNumber: form.OrgNumber,
- StartDate: form.StartDate,
- Mission: form.Mission,
- DescriptionShort: form.DescriptionShort,
- DescriptionLong: form.DescriptionLong,
- SiteLanguage: form.SiteLanguage,
- AddressLine1: form.AddressLine1,
- AddressLine2: form.AddressLine2,
- AddressCity: form.AddressCity,
- AddressRegion: form.AddressRegion,
- AddressZIP: form.AddressZIP,
- AddressCountry: form.AddressCountry,
- })
-
- templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
- if err != nil || templateID <= 0 {
- http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
- return
- }
-
- result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{
- TemplateID: templateID,
- RequestName: form.RequestName,
- GlobalData: globalData,
- FieldValues: fieldValues,
- })
- if err != nil {
- data, loadErr := u.loadBuildNewPageData(r, pageData{
- Title: "New Build",
- Err: err.Error(),
- Current: "/builds/new",
- }, form.DraftID, templateID, form, fieldValues, suggestionState)
- if loadErr != nil {
- http.Error(w, loadErr.Error(), http.StatusBadRequest)
- return
- }
- u.render.Render(w, "build_new", data)
- return
- }
-
- if form.DraftID != "" {
- _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
- DraftID: form.DraftID,
- TemplateID: int64Ptr(templateID),
- ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
- Source: form.DraftSource,
- RequestName: form.RequestName,
- GlobalData: globalData,
- FieldValues: fieldValues,
- DraftContext: buildDraftContextFromForm(form, globalData),
- SuggestionState: &suggestionState,
- Status: "submitted",
- Notes: form.DraftNotes,
- })
- }
-
- http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther)
- }
-
- func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
- return
- }
- form := buildFormInputFromRequest(r)
- form = u.applyPromptConfigForBuildFlow(r.Context(), form)
- fieldValues := parseBuildFieldValues(r)
- suggestionState := parseSuggestionStateFromRequest(r)
- templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
- if err != nil || templateID <= 0 {
- http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
- return
- }
- globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
- CompanyName: form.CompanyName,
- BusinessType: form.BusinessType,
- Username: form.Username,
- Email: form.Email,
- Phone: form.Phone,
- OrgNumber: form.OrgNumber,
- StartDate: form.StartDate,
- Mission: form.Mission,
- DescriptionShort: form.DescriptionShort,
- DescriptionLong: form.DescriptionLong,
- SiteLanguage: form.SiteLanguage,
- AddressLine1: form.AddressLine1,
- AddressLine2: form.AddressLine2,
- AddressCity: form.AddressCity,
- AddressRegion: form.AddressRegion,
- AddressZIP: form.AddressZIP,
- AddressCountry: form.AddressCountry,
- })
- draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
- DraftID: form.DraftID,
- TemplateID: int64Ptr(templateID),
- ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
- Source: form.DraftSource,
- RequestName: form.RequestName,
- GlobalData: globalData,
- FieldValues: fieldValues,
- DraftContext: buildDraftContextFromForm(form, globalData),
- SuggestionState: &suggestionState,
- Status: defaultDraftStatus(form.DraftStatus),
- Notes: form.DraftNotes,
- })
- if err != nil {
- data, loadErr := u.loadBuildNewPageData(r, pageData{
- Title: "New Build",
- Err: err.Error(),
- Current: "/builds/new",
- }, form.DraftID, templateID, form, fieldValues, suggestionState)
- if loadErr != nil {
- http.Error(w, loadErr.Error(), http.StatusBadRequest)
- return
- }
- u.render.Render(w, "build_new", data)
- return
- }
- http.Redirect(w, r, fmt.Sprintf("/builds/new?template_id=%d&draft_id=%s&msg=draft+saved", templateID, urlQuery(draft.ID)), http.StatusSeeOther)
- }
-
- func (u *UI) AutofillDraft(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
- return
- }
- form := buildFormInputFromRequest(r)
- form = u.applyPromptConfigForBuildFlow(r.Context(), form)
- fieldValues := parseBuildFieldValues(r)
- suggestionState := parseSuggestionStateFromRequest(r)
- templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
- if err != nil || templateID <= 0 {
- http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
- return
- }
-
- detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
- if err != nil || detail.Manifest == nil {
- http.Redirect(w, r, "/builds/new?err=template+detail+missing", http.StatusSeeOther)
- return
- }
-
- globalData := buildGlobalDataFromForm(form)
- draftContext := buildDraftContextFromForm(form, globalData)
- action, targetFieldPath := parseAutofillAction(strings.TrimSpace(r.FormValue("autofill_action")))
- focusFieldPath := targetFieldPath
- now := time.Now().UTC()
- req := mapping.SuggestionRequest{
- TemplateID: templateID,
- Fields: detail.Fields,
- GlobalData: globalData,
- DraftContext: draftContext,
- MasterPrompt: form.MasterPrompt,
- PromptBlocks: form.PromptBlocks,
- Existing: fieldValues,
- }
-
- msg := "autofill ready"
- switch action {
- case "generate_all":
- suggestionState = mapping.GenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now)
- msg = "suggestions generated"
- case "regenerate_all":
- suggestionState = mapping.RegenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now)
- msg = "suggestions regenerated"
- case "apply_all":
- fieldValues, suggestionState = mapping.ApplyAllSuggestions(fieldValues, suggestionState, now)
- msg = "all suggestions applied"
- case "apply_all_empty":
- fieldValues, suggestionState = mapping.ApplySuggestionsToEmptyFields(fieldValues, suggestionState, now)
- msg = "suggestions applied to empty fields"
- case "apply_field":
- fieldValues, suggestionState = mapping.ApplySuggestionToField(fieldValues, suggestionState, targetFieldPath, now)
- msg = "field suggestion applied"
- case "regenerate_field":
- suggestionState = mapping.RegenerateFieldSuggestion(r.Context(), u.suggestionGenerator, req, suggestionState, targetFieldPath, now)
- msg = "field suggestion regenerated"
- default:
- msg = "unknown autofill action"
- }
-
- if strings.TrimSpace(form.DraftID) != "" {
- _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
- DraftID: form.DraftID,
- TemplateID: int64Ptr(templateID),
- ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
- Source: form.DraftSource,
- RequestName: form.RequestName,
- GlobalData: globalData,
- FieldValues: fieldValues,
- DraftContext: draftContext,
- SuggestionState: &suggestionState,
- Status: defaultDraftStatus(form.DraftStatus),
- Notes: form.DraftNotes,
- })
- }
-
- data, loadErr := u.loadBuildNewPageData(r, pageData{
- Title: "New Build",
- Msg: msg,
- Current: "/builds/new",
- }, form.DraftID, templateID, form, fieldValues, suggestionState)
- if loadErr != nil {
- http.Error(w, loadErr.Error(), http.StatusBadRequest)
- return
- }
- data.AutofillFocusID = fieldAnchorID(focusFieldPath)
- u.render.Render(w, "build_new", data)
- }
-
- func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) {
- buildID := strings.TrimSpace(chi.URLParam(r, "id"))
- build, err := u.buildSvc.GetBuild(r.Context(), buildID)
- if err != nil {
- http.Error(w, err.Error(), http.StatusNotFound)
- return
- }
-
- status := strings.ToLower(strings.TrimSpace(build.QCStatus))
- canPoll := status == "queued" || status == "processing"
- canFetchEditor := (status == "done" || status == "failed" || status == "timeout") &&
- build.QCSiteID != nil &&
- strings.TrimSpace(build.QCEditorURL) == ""
- autoRefresh := 0
- if canPoll && u.cfg.PollIntervalSeconds > 0 {
- autoRefresh = u.cfg.PollIntervalSeconds
- }
- effectiveGlobal := build.GlobalDataJSON
- if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 {
- effectiveGlobal = payloadGlobal
- }
- u.render.Render(w, "build_detail", buildDetailPageData{
- pageData: basePageData(r, "Build Detail", "/builds"),
- Build: build,
- EffectiveGlobal: effectiveGlobal,
- CanPoll: canPoll,
- CanFetchEditorURL: canFetchEditor,
- AutoRefreshSeconds: autoRefresh,
- })
- }
-
- func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) {
- buildID := strings.TrimSpace(chi.URLParam(r, "id"))
- if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil {
- http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
- return
- }
- http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther)
- }
-
- func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) {
- buildID := strings.TrimSpace(chi.URLParam(r, "id"))
- if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil {
- http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
- return
- }
- http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther)
- }
-
- func basePageData(r *http.Request, title, current string) pageData {
- q := r.URL.Query()
- return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current}
- }
-
- func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) {
- rawID := chi.URLParam(r, "id")
- templateID, err := strconv.ParseInt(rawID, 10, 64)
- if err != nil {
- http.Error(w, "invalid template id", http.StatusBadRequest)
- return 0, false
- }
- return templateID, true
- }
-
- func urlQuery(s string) string {
- return url.QueryEscape(s)
- }
-
- func boolPtr(v bool) *bool { return &v }
- func intPtr(v int) *int { return &v }
- func int64Ptr(v int64) *int64 { return &v }
- func strPtr(v string) *string {
- return &v
- }
-
- func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string, suggestionState domain.DraftSuggestionState) (buildNewPageData, error) {
- if strings.TrimSpace(form.MasterPrompt) == "" {
- form.MasterPrompt = domain.SeedMasterPrompt
- }
- form.PromptBlocks = domain.NormalizePromptBlocks(form.PromptBlocks)
- form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
-
- templates, err := u.templateSvc.ListTemplates(r.Context())
- if err != nil {
- return buildNewPageData{}, err
- }
- drafts, err := u.draftSvc.ListDrafts(r.Context(), 50)
- if err != nil {
- return buildNewPageData{}, err
- }
-
- data := buildNewPageData{
- pageData: page,
- Templates: templates,
- Drafts: drafts,
- SelectedDraftID: selectedDraftID,
- SelectedTemplateID: selectedTemplateID,
- SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState),
- ShowDebug: parseDebugMode(r),
- Form: form,
- }
- if selectedTemplateID <= 0 {
- return data, nil
- }
-
- detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID)
- if err != nil || detail.Manifest == nil {
- return data, nil
- }
- data.SelectedManifestID = detail.Manifest.ID
- data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues, suggestionState.ByFieldPath)
- data.EnabledFields = data.EditableFields
- data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields))
- return data, nil
- }
-
- func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormInput) buildFormInput {
- settings := u.loadPromptSettings(ctx)
- form.MasterPrompt = settings.MasterPrompt
- form.PromptBlocks = clonePromptBlocks(settings.PromptBlocks)
- if strings.TrimSpace(form.DraftID) == "" {
- return form
- }
-
- draft, err := u.draftSvc.GetDraft(ctx, strings.TrimSpace(form.DraftID))
- if err != nil || draft == nil {
- return form
- }
- mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
- form.MasterPrompt = settings.MasterPrompt
- form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
- form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
- return form
- }
-
- func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string, suggestions map[string]domain.DraftSuggestion) ([]buildFieldView, []buildFieldSectionView) {
- sectionOrder := []string{
- domain.WebsiteSectionHero,
- domain.WebsiteSectionIntro,
- domain.WebsiteSectionServices,
- domain.WebsiteSectionAbout,
- domain.WebsiteSectionTeam,
- domain.WebsiteSectionTestimonials,
- domain.WebsiteSectionCTA,
- domain.WebsiteSectionContact,
- domain.WebsiteSectionGallery,
- domain.WebsiteSectionFooter,
- domain.WebsiteSectionOther,
- }
-
- sectionDescriptions := map[string]string{
- domain.WebsiteSectionHero: "Headline-nahe Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionIntro: "Intro-/Einleitungs-Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionServices: "Services-Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionAbout: "About-Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionTeam: "Team-Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionTestimonials: "Testimonial-Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionCTA: "CTA-/Highlight-Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionContact: "Kontakt-Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionGallery: "Media/Gallery-Felder, Bildfelder bleiben im MVP nicht editierbar.",
- domain.WebsiteSectionFooter: "Footer-Felder, bevorzugt nach Block-ID gruppiert.",
- domain.WebsiteSectionOther: "Aktive Textfelder ausserhalb der Kern-Sections, bevorzugt nach Block-ID gruppiert.",
- }
-
- sectionsByKey := make(map[string]buildFieldSectionView, len(sectionOrder))
- pendingByKey := make(map[string][]pendingField, len(sectionOrder))
- for _, key := range sectionOrder {
- sectionsByKey[key] = buildFieldSectionView{
- Key: key,
- Title: domain.WebsiteSectionLabel(key),
- Description: sectionDescriptions[key],
- }
- }
-
- for _, f := range fields {
- targetSection := preferredBuildSection(f)
- if isMediaOrGalleryField(f) || targetSection == domain.WebsiteSectionGallery {
- labelFallback := domain.WebsiteSectionLabel(domain.WebsiteSectionGallery) + " - " + humanizeKey(f.KeyName)
- if blockID := extractBlockID(f); blockID != "" {
- labelFallback = "Media - " + blockGroupTitle(blockID)
- }
- media := sectionsByKey[domain.WebsiteSectionGallery]
- media.DisabledFields = append(media.DisabledFields, buildFieldView{
- AnchorID: fieldAnchorID(f.Path),
- Path: f.Path,
- DisplayLabel: effectiveLabel(f, labelFallback),
- SampleValue: f.SampleValue,
- Value: "",
- })
- sectionsByKey[domain.WebsiteSectionGallery] = media
- continue
- }
- if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
- continue
- }
- suggestion := suggestions[f.Path]
- pf := pendingField{
- Field: f,
- View: buildFieldView{
- AnchorID: fieldAnchorID(f.Path),
- Path: f.Path,
- DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)),
- SampleValue: f.SampleValue,
- Value: strings.TrimSpace(fieldValues[f.Path]),
- SuggestedValue: strings.TrimSpace(suggestion.Value),
- SuggestionReason: strings.TrimSpace(suggestion.Reason),
- SuggestionStatus: strings.TrimSpace(suggestion.Status),
- SuggestionSource: strings.TrimSpace(suggestion.Source),
- },
- }
- pendingByKey[targetSection] = append(pendingByKey[targetSection], pf)
- }
-
- for _, key := range sectionOrder {
- section := sectionsByKey[key]
- items := pendingByKey[key]
- switch key {
- case domain.WebsiteSectionServices:
- section = applyServicesGrouping(section, items)
- case domain.WebsiteSectionTestimonials:
- section = applyTestimonialsGrouping(section, items)
- case domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionFooter:
- section = applyTextGrouping(section, items)
- case domain.WebsiteSectionOther:
- section = applyOtherGrouping(section, items)
- case domain.WebsiteSectionGallery:
- // Gallery fields are handled as disabled entries only in this MVP.
- default:
- section = applyOtherGrouping(section, items)
- }
- sectionsByKey[key] = section
- }
-
- sections := make([]buildFieldSectionView, 0, len(sectionOrder))
- for _, key := range sectionOrder {
- sections = append(sections, sectionsByKey[key])
- }
- return assignEditableIndexes(sections)
- }
-
- func applyServicesGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
- return applyBlockFirstGrouping(section, fields, "Services", applyServicesGroupingFallback)
- }
-
- func applyServicesGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
- titles := make([]pendingField, 0)
- descriptions := make([]pendingField, 0)
- other := make([]buildFieldView, 0)
-
- for _, pf := range fields {
- key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
- switch {
- case strings.HasPrefix(key, "servicestitle_") || strings.HasPrefix(key, "servicestitle"):
- titles = append(titles, pf)
- case strings.HasPrefix(key, "servicesdescription_") || strings.HasPrefix(key, "servicesdescription"):
- descriptions = append(descriptions, pf)
- default:
- pf.View.DisplayLabel = effectiveLabel(pf.Field, "Services - "+humanizeKey(pf.Field.KeyName))
- other = append(other, pf.View)
- }
- }
-
- maxCount := len(titles)
- if len(descriptions) > maxCount {
- maxCount = len(descriptions)
- }
- for i := 0; i < maxCount; i++ {
- block := buildFieldGroupView{Title: fmt.Sprintf("Service %d", i+1)}
- if i < len(titles) {
- item := titles[i]
- item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Titel", i+1))
- block.Fields = append(block.Fields, item.View)
- }
- if i < len(descriptions) {
- item := descriptions[i]
- item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Beschreibung", i+1))
- block.Fields = append(block.Fields, item.View)
- }
- if len(block.Fields) > 0 {
- section.EditableGroups = append(section.EditableGroups, block)
- }
- }
- section.EditableFields = append(section.EditableFields, other...)
- return section
- }
-
- func applyTestimonialsGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
- return applyBlockFirstGrouping(section, fields, "Testimonials", applyTestimonialsGroupingFallback)
- }
-
- func applyTestimonialsGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
- names := make([]pendingField, 0)
- titles := make([]pendingField, 0)
- descriptions := make([]pendingField, 0)
- other := make([]buildFieldView, 0)
-
- for _, pf := range fields {
- key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
- switch {
- case strings.HasPrefix(key, "testimonialsname_") || strings.HasPrefix(key, "testimonialsname"):
- names = append(names, pf)
- case strings.HasPrefix(key, "testimonialstitle_") || strings.HasPrefix(key, "testimonialstitle"):
- titles = append(titles, pf)
- case strings.HasPrefix(key, "testimonialsdescription_") || strings.HasPrefix(key, "testimonialsdescription"):
- descriptions = append(descriptions, pf)
- default:
- pf.View.DisplayLabel = effectiveLabel(pf.Field, "Testimonials - "+humanizeKey(pf.Field.KeyName))
- other = append(other, pf.View)
- }
- }
-
- maxCount := len(names)
- if len(titles) > maxCount {
- maxCount = len(titles)
- }
- if len(descriptions) > maxCount {
- maxCount = len(descriptions)
- }
- for i := 0; i < maxCount; i++ {
- block := buildFieldGroupView{Title: fmt.Sprintf("Testimonial %d", i+1)}
- if i < len(names) {
- item := names[i]
- item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Name", i+1))
- block.Fields = append(block.Fields, item.View)
- }
- if i < len(titles) {
- item := titles[i]
- item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Titel", i+1))
- block.Fields = append(block.Fields, item.View)
- }
- if i < len(descriptions) {
- item := descriptions[i]
- item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Beschreibung", i+1))
- block.Fields = append(block.Fields, item.View)
- }
- if len(block.Fields) > 0 {
- section.EditableGroups = append(section.EditableGroups, block)
- }
- }
- section.EditableFields = append(section.EditableFields, other...)
- return section
- }
-
- func applyTextGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
- return applyBlockFirstGrouping(section, fields, "Text", applyTextGroupingFallback)
- }
-
- func applyTextGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
- titles := make([]pendingField, 0)
- descriptions := make([]pendingField, 0)
- names := make([]pendingField, 0)
- other := make([]buildFieldView, 0)
-
- for _, pf := range fields {
- key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
- switch {
- case strings.HasPrefix(key, "texttitle_") || strings.HasPrefix(key, "texttitle") || strings.HasPrefix(key, "exttitle_") || strings.HasPrefix(key, "exttitle"):
- titles = append(titles, pf)
- case strings.HasPrefix(key, "textdescription_") || strings.HasPrefix(key, "textdescription") || strings.HasPrefix(key, "extdescription_") || strings.HasPrefix(key, "extdescription"):
- descriptions = append(descriptions, pf)
- case strings.HasPrefix(key, "textname_") || strings.HasPrefix(key, "textname") || strings.HasPrefix(key, "extname_") || strings.HasPrefix(key, "extname"):
- names = append(names, pf)
- default:
- pf.View.DisplayLabel = effectiveLabel(pf.Field, "Text - "+humanizeKey(pf.Field.KeyName))
- other = append(other, pf.View)
- }
- }
-
- maxCount := len(titles)
- if len(descriptions) > maxCount {
- maxCount = len(descriptions)
- }
- if len(names) > maxCount {
- maxCount = len(names)
- }
- for i := 0; i < maxCount; i++ {
- block := buildFieldGroupView{Title: fmt.Sprintf("Textblock %d", i+1)}
- if i < len(titles) {
- item := titles[i]
- item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Titel", i+1))
- block.Fields = append(block.Fields, item.View)
- }
- if i < len(descriptions) {
- item := descriptions[i]
- item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Beschreibung", i+1))
- block.Fields = append(block.Fields, item.View)
- }
- if i < len(names) {
- item := names[i]
- item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Name", i+1))
- block.Fields = append(block.Fields, item.View)
- }
- if len(block.Fields) > 0 {
- section.EditableGroups = append(section.EditableGroups, block)
- }
- }
- section.EditableFields = append(section.EditableFields, other...)
- return section
- }
-
- func applyOtherGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
- return applyBlockFirstGrouping(section, fields, "", applyOtherGroupingFallback)
- }
-
- func applyOtherGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
- for _, pf := range fields {
- pf.View.DisplayLabel = effectiveLabel(pf.Field, normalizedSectionTitle(pf.Field.Section)+" - "+humanizeKey(pf.Field.KeyName))
- section.EditableFields = append(section.EditableFields, pf.View)
- }
- sort.SliceStable(section.EditableFields, func(i, j int) bool {
- return section.EditableFields[i].Path < section.EditableFields[j].Path
- })
- return section
- }
-
- func applyBlockFirstGrouping(section buildFieldSectionView, fields []pendingField, fallbackPrefix string, fallback func(buildFieldSectionView, []pendingField) buildFieldSectionView) buildFieldSectionView {
- grouped := map[string][]pendingField{}
- withoutBlockID := make([]pendingField, 0)
- for _, pf := range fields {
- blockID := extractBlockID(pf.Field)
- if blockID == "" {
- withoutBlockID = append(withoutBlockID, pf)
- continue
- }
- grouped[blockID] = append(grouped[blockID], pf)
- }
-
- if len(grouped) > 0 {
- blockIDs := make([]string, 0, len(grouped))
- for blockID := range grouped {
- blockIDs = append(blockIDs, blockID)
- }
- sort.SliceStable(blockIDs, func(i, j int) bool {
- li, lj := blockSortRank(blockIDs[i]), blockSortRank(blockIDs[j])
- if li != lj {
- return li < lj
- }
- return blockIDs[i] < blockIDs[j]
- })
-
- for _, blockID := range blockIDs {
- items := grouped[blockID]
- sort.SliceStable(items, func(i, j int) bool {
- ri := deriveFieldRole(items[i].Field.KeyName)
- rj := deriveFieldRole(items[j].Field.KeyName)
- if ri.Order != rj.Order {
- return ri.Order < rj.Order
- }
- return items[i].Field.Path < items[j].Field.Path
- })
-
- group := buildFieldGroupView{Title: blockGroupTitle(blockID)}
- for _, item := range items {
- role := deriveFieldRole(item.Field.KeyName)
- fallbackLabel := role.Label
- if fallbackLabel == "" {
- fallbackLabel = humanizeKey(item.Field.KeyName)
- }
- item.View.DisplayLabel = effectiveLabel(item.Field, fallbackLabel)
- group.Fields = append(group.Fields, item.View)
- }
- if len(group.Fields) > 0 {
- section.EditableGroups = append(section.EditableGroups, group)
- }
- }
- }
-
- if len(withoutBlockID) > 0 {
- if fallback != nil {
- return fallback(section, withoutBlockID)
- }
- for _, pf := range withoutBlockID {
- labelPrefix := fallbackPrefix
- if labelPrefix == "" {
- labelPrefix = normalizedSectionTitle(pf.Field.Section)
- }
- pf.View.DisplayLabel = effectiveLabel(pf.Field, labelPrefix+" - "+humanizeKey(pf.Field.KeyName))
- section.EditableFields = append(section.EditableFields, pf.View)
- }
- }
- return section
- }
-
- func extractBlockID(f domain.TemplateField) string {
- candidates := []string{f.KeyName, f.Path}
- for _, candidate := range candidates {
- normalized := strings.ToLower(strings.TrimSpace(candidate))
- if normalized == "" {
- continue
- }
- if match := blockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
- return match[1]
- }
- if match := looseBlockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
- return match[1]
- }
- }
- return ""
- }
-
- func blockGroupTitle(blockID string) string {
- blockID = strings.ToLower(strings.TrimSpace(blockID))
- if blockID == "" {
- return "Unbekannter Block"
- }
- if known, ok := knownBlockAreas[blockID]; ok {
- return fmt.Sprintf("%s (%s)", known, blockID)
- }
- return "Block " + blockID
- }
-
- func blockSortRank(blockID string) int {
- switch strings.ToLower(strings.TrimSpace(blockID)) {
- case "m1710":
- return 10
- case "c7886":
- return 20
- case "r4830":
- return 30
- case "c2929":
- return 40
- case "r4748":
- return 50
- case "r1508":
- return 60
- case "c1165":
- return 70
- case "m4178":
- return 80
- default:
- return 1000
- }
- }
-
- func deriveFieldRole(key string) fieldRole {
- normalized := strings.ToLower(strings.TrimSpace(key))
- switch {
- case strings.Contains(normalized, "subtitle"):
- return fieldRole{Label: "Untertitel", Order: 15}
- case strings.Contains(normalized, "title"):
- return fieldRole{Label: "Titel", Order: 20}
- case strings.Contains(normalized, "description"):
- return fieldRole{Label: "Beschreibung", Order: 30}
- case strings.Contains(normalized, "name"):
- return fieldRole{Label: "Name", Order: 40}
- case strings.Contains(normalized, "button") || strings.Contains(normalized, "cta"):
- return fieldRole{Label: "CTA Text", Order: 50}
- default:
- return fieldRole{Label: humanizeKey(key), Order: 100}
- }
- }
-
- func assignEditableIndexes(sections []buildFieldSectionView) ([]buildFieldView, []buildFieldSectionView) {
- editable := make([]buildFieldView, 0)
- nextIndex := 0
- for si := range sections {
- for gi := range sections[si].EditableGroups {
- for fi := range sections[si].EditableGroups[gi].Fields {
- sections[si].EditableGroups[gi].Fields[fi].Index = nextIndex
- editable = append(editable, sections[si].EditableGroups[gi].Fields[fi])
- nextIndex++
- }
- }
- for fi := range sections[si].EditableFields {
- sections[si].EditableFields[fi].Index = nextIndex
- editable = append(editable, sections[si].EditableFields[fi])
- nextIndex++
- }
- }
- return editable, sections
- }
-
- func preferredBuildSection(f domain.TemplateField) string {
- websiteSection := strings.TrimSpace(f.WebsiteSection)
- if websiteSection != "" {
- normalized := domain.NormalizeWebsiteSection(websiteSection)
- if normalized == domain.WebsiteSectionServiceItem {
- return domain.WebsiteSectionServices
- }
- return normalized
- }
- return fallbackBuildSection(f)
- }
-
- func fallbackBuildSection(f domain.TemplateField) string {
- switch normalizedSection(f.Section) {
- case "services":
- return domain.WebsiteSectionServices
- case "testimonials":
- return domain.WebsiteSectionTestimonials
- case "text":
- if isMediaOrGalleryField(f) {
- return domain.WebsiteSectionGallery
- }
- return domain.WebsiteSectionOther
- default:
- if isMediaOrGalleryField(f) {
- return domain.WebsiteSectionGallery
- }
- return domain.WebsiteSectionOther
- }
- }
-
- func normalizedSection(raw string) string {
- section := strings.ToLower(strings.TrimSpace(raw))
- switch section {
- case "ext":
- return "text"
- default:
- return section
- }
- }
-
- func normalizedSectionTitle(raw string) string {
- switch normalizedSection(raw) {
- case "text":
- return "Text"
- case "services":
- return "Services"
- case "testimonials":
- return "Testimonials"
- case "gallery", "media":
- return "Media"
- default:
- return "Feld"
- }
- }
-
- func isMediaOrGalleryField(f domain.TemplateField) bool {
- if strings.EqualFold(strings.TrimSpace(f.FieldKind), "image") {
- return true
- }
- section := strings.ToLower(strings.TrimSpace(f.Section))
- key := strings.ToLower(strings.TrimSpace(f.KeyName))
- path := strings.ToLower(strings.TrimSpace(f.Path))
- if section == "gallery" || section == "media" {
- return true
- }
- hints := []string{"gallery", "image", "img", "photo", "picture"}
- for _, hint := range hints {
- if strings.Contains(section, hint) || strings.Contains(key, hint) || strings.Contains(path, hint) {
- return true
- }
- }
- return false
- }
-
- func effectiveLabel(f domain.TemplateField, fallback string) string {
- if !isRawPathLikeLabel(f.DisplayLabel, f.Path) {
- return strings.TrimSpace(f.DisplayLabel)
- }
- return strings.TrimSpace(fallback)
- }
-
- func isRawPathLikeLabel(label string, path string) bool {
- l := strings.TrimSpace(label)
- if l == "" {
- return true
- }
- if strings.EqualFold(l, strings.TrimSpace(path)) {
- return true
- }
- if strings.Contains(l, ".") || strings.Contains(l, "_") {
- return true
- }
- return false
- }
-
- func humanizeKey(key string) string {
- raw := strings.TrimSpace(key)
- if raw == "" {
- return "Inhalt"
- }
- base := raw
- if idx := strings.Index(base, "_"); idx > 0 {
- base = base[:idx]
- }
- runes := make([]rune, 0, len(base)+4)
- for i, r := range base {
- if i > 0 && unicode.IsUpper(r) {
- runes = append(runes, ' ')
- }
- runes = append(runes, r)
- }
- human := strings.TrimSpace(string(runes))
- if human == "" {
- return "Inhalt"
- }
- words := strings.Fields(strings.ToLower(human))
- for i := range words {
- if len(words[i]) > 0 {
- words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
- }
- }
- return strings.Join(words, " ")
- }
-
- func buildFormInputFromRequest(r *http.Request) buildFormInput {
- form := buildFormInput{
- DraftID: strings.TrimSpace(r.FormValue("draft_id")),
- DraftSource: strings.TrimSpace(r.FormValue("draft_source")),
- DraftStatus: strings.TrimSpace(r.FormValue("draft_status")),
- DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")),
- RequestName: strings.TrimSpace(r.FormValue("request_name")),
- CompanyName: strings.TrimSpace(r.FormValue("company_name")),
- BusinessType: strings.TrimSpace(r.FormValue("business_type")),
- Username: strings.TrimSpace(r.FormValue("username")),
- Email: strings.TrimSpace(r.FormValue("email")),
- Phone: strings.TrimSpace(r.FormValue("phone")),
- OrgNumber: strings.TrimSpace(r.FormValue("org_number")),
- StartDate: strings.TrimSpace(r.FormValue("start_date")),
- Mission: strings.TrimSpace(r.FormValue("mission")),
- DescriptionShort: strings.TrimSpace(r.FormValue("description_short")),
- DescriptionLong: strings.TrimSpace(r.FormValue("description_long")),
- SiteLanguage: strings.TrimSpace(r.FormValue("site_language")),
- AddressLine1: strings.TrimSpace(r.FormValue("address_line1")),
- AddressLine2: strings.TrimSpace(r.FormValue("address_line2")),
- AddressCity: strings.TrimSpace(r.FormValue("address_city")),
- AddressRegion: strings.TrimSpace(r.FormValue("address_region")),
- AddressZIP: strings.TrimSpace(r.FormValue("address_zip")),
- AddressCountry: strings.TrimSpace(r.FormValue("address_country")),
- WebsiteURL: strings.TrimSpace(r.FormValue("website_url")),
- WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")),
- LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")),
- MarketStyle: strings.TrimSpace(r.FormValue("market_style")),
- AddressMode: strings.TrimSpace(r.FormValue("address_mode")),
- ContentTone: strings.TrimSpace(r.FormValue("content_tone")),
- }
- return form
- }
-
- func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput {
- form := buildFormInput{
- DraftID: draft.ID,
- DraftSource: draft.Source,
- DraftStatus: draft.Status,
- DraftNotes: draft.Notes,
- RequestName: draft.RequestName,
- }
- mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON)
- mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
- return form
- }
-
- func parseBuildFieldValues(r *http.Request) map[string]string {
- fieldValues := map[string]string{}
- count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count")))
- for i := 0; i < count; i++ {
- path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
- value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i)))
- if path != "" {
- fieldValues[path] = value
- }
- }
- return fieldValues
- }
-
- func parseSuggestionStateFromRequest(r *http.Request) domain.DraftSuggestionState {
- return parseSuggestionStateRaw(strings.TrimSpace(r.FormValue("suggestion_state_json")))
- }
-
- func parseSuggestionStateJSON(raw []byte) domain.DraftSuggestionState {
- return parseSuggestionStateRaw(strings.TrimSpace(string(raw)))
- }
-
- func parseSuggestionStateRaw(raw string) domain.DraftSuggestionState {
- if strings.TrimSpace(raw) == "" {
- return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
- }
- var state domain.DraftSuggestionState
- if err := json.Unmarshal([]byte(raw), &state); err != nil {
- return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
- }
- if state.ByFieldPath == nil {
- state.ByFieldPath = map[string]domain.DraftSuggestion{}
- }
- return state
- }
-
- func encodeSuggestionStateJSON(state domain.DraftSuggestionState) string {
- normalized := state
- if normalized.ByFieldPath == nil {
- normalized.ByFieldPath = map[string]domain.DraftSuggestion{}
- }
- raw, err := json.Marshal(normalized)
- if err != nil {
- return `{"byFieldPath":{}}`
- }
- return string(raw)
- }
-
- func parseAutofillAction(raw string) (string, string) {
- value := strings.TrimSpace(raw)
- if value == "" {
- return "", ""
- }
- parts := strings.SplitN(value, "::", 2)
- action := strings.TrimSpace(parts[0])
- if len(parts) == 1 {
- return action, ""
- }
- return action, strings.TrimSpace(parts[1])
- }
-
- func parseDebugMode(r *http.Request) bool {
- if r == nil {
- return false
- }
- value := strings.ToLower(strings.TrimSpace(r.FormValue("debug")))
- switch value {
- case "1", "true", "on", "yes":
- return true
- default:
- return false
- }
- }
-
- func fieldAnchorID(fieldPath string) string {
- path := strings.TrimSpace(strings.ToLower(fieldPath))
- if path == "" {
- return ""
- }
- var b strings.Builder
- b.Grow(len(path) + len("field-"))
- b.WriteString("field-")
- lastDash := false
- for _, r := range path {
- if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
- b.WriteRune(r)
- lastDash = false
- continue
- }
- if !lastDash {
- b.WriteByte('-')
- lastDash = true
- }
- }
- out := strings.Trim(b.String(), "-")
- if out == "" || out == "field" {
- return "field-anchor"
- }
- return out
- }
-
- func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) {
- if len(raw) == 0 {
- return nil, nil
- }
- var payload struct {
- GlobalData map[string]any `json:"globalData"`
- }
- if err := json.Unmarshal(raw, &payload); err != nil {
- return nil, err
- }
- if len(payload.GlobalData) == 0 {
- return nil, nil
- }
- data, err := json.Marshal(payload.GlobalData)
- if err != nil {
- return nil, err
- }
- return data, nil
- }
-
- func parseFieldValuesJSON(raw []byte) map[string]string {
- out := map[string]string{}
- if len(raw) == 0 {
- return out
- }
- _ = json.Unmarshal(raw, &out)
- return out
- }
-
- func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) {
- if form == nil || len(raw) == 0 {
- return
- }
- var global map[string]any
- if err := json.Unmarshal(raw, &global); err != nil {
- return
- }
- form.CompanyName = getString(global["companyName"])
- form.BusinessType = getString(global["businessType"])
- form.Username = getString(global["username"])
- form.Email = getString(global["email"])
- form.Phone = getString(global["phone"])
- form.OrgNumber = getString(global["orgNumber"])
- form.StartDate = getString(global["startDate"])
- form.Mission = getString(global["mission"])
- form.DescriptionShort = getString(global["descriptionShort"])
- form.DescriptionLong = getString(global["descriptionLong"])
- form.SiteLanguage = getString(global["siteLanguage"])
- address, _ := global["address"].(map[string]any)
- form.AddressLine1 = getString(address["line1"])
- form.AddressLine2 = getString(address["line2"])
- form.AddressCity = getString(address["city"])
- form.AddressRegion = getString(address["region"])
- form.AddressZIP = getString(address["zip"])
- form.AddressCountry = getString(address["country"])
- }
-
- func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) {
- if form == nil || len(raw) == 0 {
- return
- }
- var ctx domain.DraftContext
- if err := json.Unmarshal(raw, &ctx); err != nil {
- return
- }
- if strings.TrimSpace(form.BusinessType) == "" {
- form.BusinessType = strings.TrimSpace(ctx.LLM.BusinessType)
- }
- form.WebsiteURL = strings.TrimSpace(ctx.LLM.WebsiteURL)
- form.WebsiteSummary = strings.TrimSpace(ctx.LLM.WebsiteSummary)
- form.LocaleStyle = strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle)
- form.MarketStyle = strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle)
- form.AddressMode = strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode)
- form.ContentTone = strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone)
- form.PromptInstructions = strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions)
- form.PromptBlocks = clonePromptBlocks(ctx.LLM.Prompt.Blocks)
- }
-
- func buildGlobalDataFromForm(form buildFormInput) map[string]any {
- return buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
- CompanyName: form.CompanyName,
- BusinessType: form.BusinessType,
- Username: form.Username,
- Email: form.Email,
- Phone: form.Phone,
- OrgNumber: form.OrgNumber,
- StartDate: form.StartDate,
- Mission: form.Mission,
- DescriptionShort: form.DescriptionShort,
- DescriptionLong: form.DescriptionLong,
- SiteLanguage: form.SiteLanguage,
- AddressLine1: form.AddressLine1,
- AddressLine2: form.AddressLine2,
- AddressCity: form.AddressCity,
- AddressRegion: form.AddressRegion,
- AddressZIP: form.AddressZIP,
- AddressCountry: form.AddressCountry,
- })
- }
-
- func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext {
- businessType := strings.TrimSpace(form.BusinessType)
- if businessType == "" {
- businessType = strings.TrimSpace(getString(globalData["businessType"]))
- }
- return &domain.DraftContext{
- IntakeSource: strings.TrimSpace(form.DraftSource),
- LLM: domain.DraftLLMContext{
- BusinessType: businessType,
- WebsiteURL: strings.TrimSpace(form.WebsiteURL),
- WebsiteSummary: strings.TrimSpace(form.WebsiteSummary),
- StyleProfile: domain.DraftStyleProfile{
- LocaleStyle: strings.TrimSpace(form.LocaleStyle),
- MarketStyle: strings.TrimSpace(form.MarketStyle),
- AddressMode: strings.TrimSpace(form.AddressMode),
- ContentTone: strings.TrimSpace(form.ContentTone),
- PromptInstructions: strings.TrimSpace(form.PromptInstructions),
- },
- Prompt: domain.DraftPromptConfig{
- Blocks: clonePromptBlocks(form.PromptBlocks),
- },
- },
- }
- }
-
- func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings {
- settings := domain.AppSettings{
- QCBaseURL: u.cfg.QCBaseURL,
- QCBearerTokenEncrypted: u.cfg.QCToken,
- LanguageOutputMode: "EN",
- JobPollIntervalSeconds: u.cfg.PollIntervalSeconds,
- JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
- MasterPrompt: domain.SeedMasterPrompt,
- PromptBlocks: domain.DefaultPromptBlocks(),
- }
- if u.settings == nil {
- return settings
- }
- stored, err := u.settings.GetSettings(ctx)
- if err != nil || stored == nil {
- return settings
- }
- if strings.TrimSpace(stored.QCBaseURL) != "" {
- settings.QCBaseURL = strings.TrimSpace(stored.QCBaseURL)
- }
- if strings.TrimSpace(stored.QCBearerTokenEncrypted) != "" {
- settings.QCBearerTokenEncrypted = strings.TrimSpace(stored.QCBearerTokenEncrypted)
- }
- if strings.TrimSpace(stored.LanguageOutputMode) != "" {
- settings.LanguageOutputMode = strings.TrimSpace(stored.LanguageOutputMode)
- }
- if stored.JobPollIntervalSeconds > 0 {
- settings.JobPollIntervalSeconds = stored.JobPollIntervalSeconds
- }
- if stored.JobPollTimeoutSeconds > 0 {
- settings.JobPollTimeoutSeconds = stored.JobPollTimeoutSeconds
- }
- settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt)
- settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks)
- return settings
- }
-
- func parsePromptBlocksFromRequest(r *http.Request) []domain.PromptBlockConfig {
- count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("prompt_block_count")))
- if count <= 0 {
- return domain.DefaultPromptBlocks()
- }
- out := make([]domain.PromptBlockConfig, 0, count)
- for i := 0; i < count; i++ {
- id := strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_id_%d", i)))
- if id == "" {
- continue
- }
- out = append(out, domain.PromptBlockConfig{
- ID: id,
- Label: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_label_%d", i))),
- Instruction: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_instruction_%d", i))),
- Enabled: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_enabled_%d", i))) == "on",
- })
- }
- return domain.NormalizePromptBlocks(out)
- }
-
- func clonePromptBlocks(blocks []domain.PromptBlockConfig) []domain.PromptBlockConfig {
- if len(blocks) == 0 {
- return nil
- }
- out := make([]domain.PromptBlockConfig, len(blocks))
- copy(out, blocks)
- return out
- }
-
- func mergePromptBlocks(current []domain.PromptBlockConfig, defaults []domain.PromptBlockConfig) []domain.PromptBlockConfig {
- merged := make([]domain.PromptBlockConfig, 0, len(defaults))
- merged = append(merged, clonePromptBlocks(defaults)...)
- overrides := make(map[string]domain.PromptBlockConfig, len(current))
- for _, block := range current {
- id := strings.TrimSpace(block.ID)
- if id == "" {
- continue
- }
- overrides[id] = block
- }
- for i := range merged {
- if override, ok := overrides[merged[i].ID]; ok {
- if strings.TrimSpace(override.Label) != "" {
- merged[i].Label = strings.TrimSpace(override.Label)
- }
- if strings.TrimSpace(override.Instruction) != "" {
- merged[i].Instruction = strings.TrimSpace(override.Instruction)
- }
- merged[i].Enabled = override.Enabled
- delete(overrides, merged[i].ID)
- }
- }
- for _, override := range overrides {
- merged = append(merged, override)
- }
- return domain.NormalizePromptBlocks(merged)
- }
-
- func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form buildFormInput) []domain.PromptBlockConfig {
- out := clonePromptBlocks(blocks)
- if len(out) == 0 {
- return out
- }
- for i := range out {
- switch out[i].ID {
- case "business_type":
- if strings.TrimSpace(form.BusinessType) != "" {
- out[i].Enabled = true
- }
- case "website_summary":
- if strings.TrimSpace(form.WebsiteSummary) != "" {
- out[i].Enabled = true
- }
- case "address_mode":
- if strings.TrimSpace(form.AddressMode) != "" {
- out[i].Enabled = true
- }
- case "content_tone":
- if strings.TrimSpace(form.ContentTone) != "" {
- out[i].Enabled = true
- }
- case "free_instructions":
- if strings.TrimSpace(form.PromptInstructions) != "" {
- out[i].Enabled = true
- }
- }
- }
- return out
- }
-
- func semanticSlotPreview(mappingResult mapping.SemanticSlotMapping) []semanticSlotPreviewView {
- if len(mappingResult.BySlot) == 0 {
- return nil
- }
- slotKeys := make([]string, 0, len(mappingResult.BySlot))
- for slot := range mappingResult.BySlot {
- slotKeys = append(slotKeys, slot)
- }
- sort.Strings(slotKeys)
-
- out := make([]semanticSlotPreviewView, 0, len(slotKeys))
- for _, slot := range slotKeys {
- targets := mappingResult.BySlot[slot]
- examples := make([]string, 0, 2)
- for i := 0; i < len(targets) && i < 2; i++ {
- examples = append(examples, targets[i].FieldPath)
- }
- out = append(out, semanticSlotPreviewView{
- Slot: slot,
- Count: len(targets),
- Examples: strings.Join(examples, ", "),
- })
- }
- return out
- }
-
- func getString(v any) string {
- s, _ := v.(string)
- return strings.TrimSpace(s)
- }
-
- func defaultDraftStatus(status string) string {
- switch strings.ToLower(strings.TrimSpace(status)) {
- case "reviewed", "submitted":
- return strings.ToLower(strings.TrimSpace(status))
- default:
- return "draft"
- }
- }
-
- func websiteSectionOptions() []websiteSectionOptionView {
- values := domain.WebsiteSectionOptions()
- out := make([]websiteSectionOptionView, 0, len(values))
- for _, value := range values {
- out = append(out, websiteSectionOptionView{
- Value: value,
- Label: domain.WebsiteSectionLabel(value),
- })
- }
- return out
- }
|