Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

2216 wiersze
74KB

  1. package handlers
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "net/url"
  9. "regexp"
  10. "sort"
  11. "strconv"
  12. "strings"
  13. "time"
  14. "unicode"
  15. "github.com/go-chi/chi/v5"
  16. "qctextbuilder/internal/buildsvc"
  17. "qctextbuilder/internal/config"
  18. "qctextbuilder/internal/domain"
  19. "qctextbuilder/internal/draftsvc"
  20. "qctextbuilder/internal/llmruntime"
  21. "qctextbuilder/internal/mapping"
  22. "qctextbuilder/internal/onboarding"
  23. "qctextbuilder/internal/store"
  24. "qctextbuilder/internal/templatesvc"
  25. )
  26. type UI struct {
  27. templateSvc *templatesvc.Service
  28. onboardSvc *onboarding.Service
  29. draftSvc *draftsvc.Service
  30. buildSvc buildsvc.Service
  31. settings store.SettingsStore
  32. suggestionGenerator mapping.SuggestionGenerator
  33. cfg config.Config
  34. render htmlRenderer
  35. logger *slog.Logger
  36. }
  37. type htmlRenderer interface {
  38. Render(w http.ResponseWriter, name string, data any)
  39. }
  40. type pageData struct {
  41. Title string
  42. Msg string
  43. Err string
  44. Current string
  45. }
  46. type homePageData struct {
  47. pageData
  48. TemplateCount int
  49. }
  50. type settingsPageData struct {
  51. pageData
  52. QCBaseURL string
  53. PollIntervalSeconds int
  54. PollTimeoutSeconds int
  55. PollMaxConcurrent int
  56. TokenConfigured bool
  57. LanguageOutputMode string
  58. LLMProviderOptions []domain.LLMProviderOption
  59. LLMModelOptions []domain.LLMModelOption
  60. LLMActiveProvider string
  61. LLMActiveModel string
  62. LLMBaseURL string
  63. LLMTemperature float64
  64. LLMMaxTokens int
  65. OpenAIKeyConfigured bool
  66. AnthropicKeyConfigured bool
  67. GoogleKeyConfigured bool
  68. XAIKeyConfigured bool
  69. OllamaKeyConfigured bool
  70. GooglePlacesKeyConfigured bool
  71. MasterPrompt string
  72. PromptBlocks []domain.PromptBlockConfig
  73. }
  74. type templatesPageData struct {
  75. pageData
  76. Templates []domain.Template
  77. }
  78. type templateFieldView struct {
  79. Path string
  80. FieldKind string
  81. IsEnabled bool
  82. IsRequiredByUs bool
  83. DisplayLabel string
  84. DisplayOrder int
  85. WebsiteSection string
  86. Notes string
  87. SampleValue string
  88. SemanticSlot string
  89. SemanticSlotIsKnown bool
  90. MappingSource string
  91. MappingConfidence float64
  92. }
  93. type websiteSectionOptionView struct {
  94. Value string
  95. Label string
  96. }
  97. type semanticSlotOptionView struct {
  98. Value string
  99. Label string
  100. }
  101. type templateDetailPageData struct {
  102. pageData
  103. Detail *templatesvc.TemplateDetail
  104. Fields []templateFieldView
  105. WebsiteSectionOptions []websiteSectionOptionView
  106. SemanticSlotOptions []semanticSlotOptionView
  107. }
  108. type buildFieldView struct {
  109. Index int
  110. AnchorID string
  111. Path string
  112. DisplayLabel string
  113. SampleValue string
  114. Value string
  115. SuggestedValue string
  116. SuggestionReason string
  117. SuggestionStatus string
  118. SuggestionSource string
  119. }
  120. type buildFieldGroupView struct {
  121. Title string
  122. Fields []buildFieldView
  123. }
  124. type buildFieldSectionView struct {
  125. Key string
  126. Title string
  127. Description string
  128. EditableGroups []buildFieldGroupView
  129. EditableFields []buildFieldView
  130. DisabledFields []buildFieldView
  131. }
  132. type semanticSlotPreviewView struct {
  133. Slot string
  134. Count int
  135. Examples string
  136. }
  137. type pendingField struct {
  138. Field domain.TemplateField
  139. View buildFieldView
  140. }
  141. type fieldRole struct {
  142. Label string
  143. Order int
  144. }
  145. var blockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`)
  146. var looseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`)
  147. var knownBlockAreas = map[string]string{
  148. "m1710": "Hero / Haupttitel",
  149. "c7886": "Intro / Einleitung",
  150. "r4830": "Services",
  151. "m4178": "Gallery / Medien",
  152. "c2929": "Ueber uns / About",
  153. "r4748": "Team",
  154. "r1508": "Testimonials",
  155. "c1165": "CTA / Highlight / Banner",
  156. }
  157. type buildNewPageData struct {
  158. pageData
  159. Templates []domain.Template
  160. Drafts []domain.BuildDraft
  161. SelectedDraftID string
  162. SelectedTemplateID int64
  163. SelectedManifestID string
  164. FieldSections []buildFieldSectionView
  165. EditableFields []buildFieldView
  166. EnabledFields []buildFieldView
  167. SuggestionStateJSON string
  168. AutofillFocusID string
  169. ShowDebug bool
  170. Form buildFormInput
  171. SemanticSlots []semanticSlotPreviewView
  172. }
  173. type buildFormInput struct {
  174. DraftID string
  175. DraftSource string
  176. DraftStatus string
  177. DraftNotes string
  178. RequestName string
  179. CompanyName string
  180. BusinessType string
  181. Username string
  182. Email string
  183. Phone string
  184. OrgNumber string
  185. StartDate string
  186. Mission string
  187. DescriptionShort string
  188. DescriptionLong string
  189. SiteLanguage string
  190. AddressLine1 string
  191. AddressLine2 string
  192. AddressCity string
  193. AddressRegion string
  194. AddressZIP string
  195. AddressCountry string
  196. WebsiteURL string
  197. WebsiteSummary string
  198. LocaleStyle string
  199. MarketStyle string
  200. AddressMode string
  201. ContentTone string
  202. PromptInstructions string
  203. MasterPrompt string
  204. PromptBlocks []domain.PromptBlockConfig
  205. }
  206. type buildDetailPageData struct {
  207. pageData
  208. Build *domain.SiteBuild
  209. EffectiveGlobal []byte
  210. CanPoll bool
  211. CanFetchEditorURL bool
  212. AutoRefreshSeconds int
  213. }
  214. 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, logger *slog.Logger) *UI {
  215. if logger == nil {
  216. logger = slog.Default()
  217. }
  218. return &UI{
  219. templateSvc: templateSvc,
  220. onboardSvc: onboardSvc,
  221. draftSvc: draftSvc,
  222. buildSvc: buildSvc,
  223. settings: settings,
  224. suggestionGenerator: suggestionGenerator,
  225. cfg: cfg,
  226. render: render,
  227. logger: logger,
  228. }
  229. }
  230. func (u *UI) Home(w http.ResponseWriter, r *http.Request) {
  231. templates, err := u.templateSvc.ListTemplates(r.Context())
  232. if err != nil {
  233. u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0})
  234. return
  235. }
  236. u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)})
  237. }
  238. func (u *UI) Settings(w http.ResponseWriter, r *http.Request) {
  239. settings := u.loadPromptSettings(r.Context())
  240. activeProvider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
  241. modelOptions := domain.LLMModelsByProvider(activeProvider)
  242. u.render.Render(w, "settings", settingsPageData{
  243. pageData: basePageData(r, "Settings", "/settings"),
  244. QCBaseURL: u.cfg.QCBaseURL,
  245. PollIntervalSeconds: u.cfg.PollIntervalSeconds,
  246. PollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
  247. PollMaxConcurrent: u.cfg.PollMaxConcurrent,
  248. TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "",
  249. LanguageOutputMode: "EN",
  250. LLMProviderOptions: domain.LLMProviderOptions(),
  251. LLMModelOptions: modelOptions,
  252. LLMActiveProvider: activeProvider,
  253. LLMActiveModel: domain.NormalizeLLMModel(activeProvider, settings.LLMActiveModel),
  254. LLMBaseURL: strings.TrimSpace(settings.LLMBaseURL),
  255. LLMTemperature: domain.NormalizeLLMTemperature(settings.LLMTemperature),
  256. LLMMaxTokens: domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens),
  257. OpenAIKeyConfigured: strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) != "",
  258. AnthropicKeyConfigured: strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) != "",
  259. GoogleKeyConfigured: strings.TrimSpace(settings.GoogleAPIKeyEncrypted) != "",
  260. XAIKeyConfigured: strings.TrimSpace(settings.XAIAPIKeyEncrypted) != "",
  261. OllamaKeyConfigured: strings.TrimSpace(settings.OllamaAPIKeyEncrypted) != "",
  262. GooglePlacesKeyConfigured: strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted) != "",
  263. MasterPrompt: settings.MasterPrompt,
  264. PromptBlocks: settings.PromptBlocks,
  265. })
  266. }
  267. func (u *UI) SavePromptSettings(w http.ResponseWriter, r *http.Request) {
  268. if err := r.ParseForm(); err != nil {
  269. http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther)
  270. return
  271. }
  272. settings := u.loadPromptSettings(r.Context())
  273. settings.MasterPrompt = domain.NormalizeMasterPrompt(r.FormValue("master_prompt"))
  274. settings.PromptBlocks = parsePromptBlocksFromRequest(r)
  275. if err := u.settings.UpsertSettings(r.Context(), settings); err != nil {
  276. http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther)
  277. return
  278. }
  279. http.Redirect(w, r, "/settings?msg=prompt+settings+saved", http.StatusSeeOther)
  280. }
  281. func (u *UI) SaveLLMSettings(w http.ResponseWriter, r *http.Request) {
  282. if err := r.ParseForm(); err != nil {
  283. http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther)
  284. return
  285. }
  286. settings, err := applyLLMSettingsForm(u.loadPromptSettings(r.Context()), r)
  287. if err != nil {
  288. http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther)
  289. return
  290. }
  291. if err := u.settings.UpsertSettings(r.Context(), settings); err != nil {
  292. http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther)
  293. return
  294. }
  295. http.Redirect(w, r, "/settings?msg=llm+settings+saved", http.StatusSeeOther)
  296. }
  297. func (u *UI) ValidateLLMSettings(w http.ResponseWriter, r *http.Request) {
  298. if err := r.ParseForm(); err != nil {
  299. http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther)
  300. return
  301. }
  302. settings, err := applyLLMSettingsForm(u.loadPromptSettings(r.Context()), r)
  303. if err != nil {
  304. http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther)
  305. return
  306. }
  307. if err := validateLLMProviderConfig(r.Context(), settings, u.logger); err != nil {
  308. http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther)
  309. return
  310. }
  311. msg := fmt.Sprintf("llm provider config validated (%s / %s)", settings.LLMActiveProvider, settings.LLMActiveModel)
  312. http.Redirect(w, r, "/settings?msg="+urlQuery(msg), http.StatusSeeOther)
  313. }
  314. func (u *UI) Templates(w http.ResponseWriter, r *http.Request) {
  315. templates, err := u.templateSvc.ListTemplates(r.Context())
  316. if err != nil {
  317. http.Error(w, err.Error(), http.StatusBadRequest)
  318. return
  319. }
  320. u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates})
  321. }
  322. func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) {
  323. if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil {
  324. http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther)
  325. return
  326. }
  327. http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther)
  328. }
  329. func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) {
  330. templateID, ok := parseTemplateID(w, r)
  331. if !ok {
  332. return
  333. }
  334. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
  335. if err != nil {
  336. http.Error(w, err.Error(), http.StatusNotFound)
  337. return
  338. }
  339. slotOpts := semanticSlotOptions()
  340. knownSlots := make(map[string]bool, len(slotOpts))
  341. for _, opt := range slotOpts {
  342. knownSlots[opt.Value] = true
  343. }
  344. fields := make([]templateFieldView, 0, len(detail.Fields))
  345. for _, f := range detail.Fields {
  346. slot := strings.TrimSpace(f.SemanticSlot)
  347. fields = append(fields, templateFieldView{
  348. Path: f.Path,
  349. FieldKind: f.FieldKind,
  350. IsEnabled: f.IsEnabled,
  351. IsRequiredByUs: f.IsRequiredByUs,
  352. DisplayLabel: f.DisplayLabel,
  353. DisplayOrder: f.DisplayOrder,
  354. WebsiteSection: domain.NormalizeWebsiteSection(f.WebsiteSection),
  355. Notes: f.Notes,
  356. SampleValue: f.SampleValue,
  357. SemanticSlot: slot,
  358. SemanticSlotIsKnown: slot == "" || knownSlots[slot],
  359. MappingSource: f.MappingSource,
  360. MappingConfidence: f.MappingConfidence,
  361. })
  362. }
  363. u.render.Render(w, "template_detail", templateDetailPageData{
  364. pageData: basePageData(r, "Template Detail", "/templates"),
  365. Detail: detail,
  366. Fields: fields,
  367. WebsiteSectionOptions: websiteSectionOptions(),
  368. SemanticSlotOptions: slotOpts,
  369. })
  370. }
  371. func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) {
  372. templateID, ok := parseTemplateID(w, r)
  373. if !ok {
  374. return
  375. }
  376. if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil {
  377. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
  378. return
  379. }
  380. http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther)
  381. }
  382. func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
  383. templateID, ok := parseTemplateID(w, r)
  384. if !ok {
  385. return
  386. }
  387. if err := r.ParseForm(); err != nil {
  388. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther)
  389. return
  390. }
  391. count, _ := strconv.Atoi(r.FormValue("field_count"))
  392. patches := make([]onboarding.FieldPatch, 0, count)
  393. for i := 0; i < count; i++ {
  394. path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
  395. if path == "" {
  396. continue
  397. }
  398. enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on"
  399. required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on"
  400. label := r.FormValue(fmt.Sprintf("field_label_%d", i))
  401. notes := r.FormValue(fmt.Sprintf("field_notes_%d", i))
  402. websiteSection := r.FormValue(fmt.Sprintf("field_website_section_%d", i))
  403. order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i))))
  404. if err != nil {
  405. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther)
  406. return
  407. }
  408. semanticSlot := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_semantic_slot_%d", i)))
  409. patches = append(patches, onboarding.FieldPatch{
  410. Path: path,
  411. IsEnabled: boolPtr(enabled),
  412. IsRequiredByUs: boolPtr(required),
  413. DisplayLabel: strPtr(label),
  414. DisplayOrder: intPtr(order),
  415. WebsiteSection: strPtr(websiteSection),
  416. Notes: strPtr(notes),
  417. SemanticSlot: strPtr(semanticSlot),
  418. })
  419. }
  420. manifestID := r.FormValue("manifest_id")
  421. if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil {
  422. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
  423. return
  424. }
  425. http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther)
  426. }
  427. func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) {
  428. settings := u.loadPromptSettings(r.Context())
  429. selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64)
  430. selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id"))
  431. form := buildFormInput{
  432. DraftID: selectedDraftID,
  433. DraftSource: "ui",
  434. DraftStatus: "draft",
  435. MasterPrompt: settings.MasterPrompt,
  436. PromptBlocks: clonePromptBlocks(settings.PromptBlocks),
  437. }
  438. fieldValues := map[string]string{}
  439. suggestionState := domain.DraftSuggestionState{}
  440. if selectedDraftID != "" {
  441. draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID)
  442. if err == nil {
  443. selectedTemplateID = draft.TemplateID
  444. form = buildFormInputFromDraft(draft)
  445. fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON)
  446. suggestionState = parseSuggestionStateJSON(draft.SuggestionStateJSON)
  447. form.MasterPrompt = settings.MasterPrompt
  448. form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
  449. }
  450. }
  451. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  452. data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues, suggestionState)
  453. if err != nil {
  454. http.Error(w, err.Error(), http.StatusBadRequest)
  455. return
  456. }
  457. u.render.Render(w, "build_new", data)
  458. }
  459. func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
  460. if err := r.ParseForm(); err != nil {
  461. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  462. return
  463. }
  464. form := buildFormInputFromRequest(r)
  465. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  466. fieldValues := parseBuildFieldValues(r)
  467. suggestionState := parseSuggestionStateFromRequest(r)
  468. globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  469. CompanyName: form.CompanyName,
  470. BusinessType: form.BusinessType,
  471. Username: form.Username,
  472. Email: form.Email,
  473. Phone: form.Phone,
  474. OrgNumber: form.OrgNumber,
  475. StartDate: form.StartDate,
  476. Mission: form.Mission,
  477. DescriptionShort: form.DescriptionShort,
  478. DescriptionLong: form.DescriptionLong,
  479. SiteLanguage: form.SiteLanguage,
  480. AddressLine1: form.AddressLine1,
  481. AddressLine2: form.AddressLine2,
  482. AddressCity: form.AddressCity,
  483. AddressRegion: form.AddressRegion,
  484. AddressZIP: form.AddressZIP,
  485. AddressCountry: form.AddressCountry,
  486. })
  487. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  488. if err != nil || templateID <= 0 {
  489. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  490. return
  491. }
  492. result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{
  493. TemplateID: templateID,
  494. RequestName: form.RequestName,
  495. GlobalData: globalData,
  496. FieldValues: fieldValues,
  497. })
  498. if err != nil {
  499. data, loadErr := u.loadBuildNewPageData(r, pageData{
  500. Title: "New Build",
  501. Err: err.Error(),
  502. Current: "/builds/new",
  503. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  504. if loadErr != nil {
  505. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  506. return
  507. }
  508. u.render.Render(w, "build_new", data)
  509. return
  510. }
  511. if form.DraftID != "" {
  512. _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  513. DraftID: form.DraftID,
  514. TemplateID: int64Ptr(templateID),
  515. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  516. Source: form.DraftSource,
  517. RequestName: form.RequestName,
  518. GlobalData: globalData,
  519. FieldValues: fieldValues,
  520. DraftContext: buildDraftContextFromForm(form, globalData),
  521. SuggestionState: &suggestionState,
  522. Status: "submitted",
  523. Notes: form.DraftNotes,
  524. })
  525. }
  526. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther)
  527. }
  528. func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
  529. if err := r.ParseForm(); err != nil {
  530. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  531. return
  532. }
  533. form := buildFormInputFromRequest(r)
  534. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  535. fieldValues := parseBuildFieldValues(r)
  536. suggestionState := parseSuggestionStateFromRequest(r)
  537. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  538. if err != nil || templateID <= 0 {
  539. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  540. return
  541. }
  542. globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  543. CompanyName: form.CompanyName,
  544. BusinessType: form.BusinessType,
  545. Username: form.Username,
  546. Email: form.Email,
  547. Phone: form.Phone,
  548. OrgNumber: form.OrgNumber,
  549. StartDate: form.StartDate,
  550. Mission: form.Mission,
  551. DescriptionShort: form.DescriptionShort,
  552. DescriptionLong: form.DescriptionLong,
  553. SiteLanguage: form.SiteLanguage,
  554. AddressLine1: form.AddressLine1,
  555. AddressLine2: form.AddressLine2,
  556. AddressCity: form.AddressCity,
  557. AddressRegion: form.AddressRegion,
  558. AddressZIP: form.AddressZIP,
  559. AddressCountry: form.AddressCountry,
  560. })
  561. draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  562. DraftID: form.DraftID,
  563. TemplateID: int64Ptr(templateID),
  564. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  565. Source: form.DraftSource,
  566. RequestName: form.RequestName,
  567. GlobalData: globalData,
  568. FieldValues: fieldValues,
  569. DraftContext: buildDraftContextFromForm(form, globalData),
  570. SuggestionState: &suggestionState,
  571. Status: defaultDraftStatus(form.DraftStatus),
  572. Notes: form.DraftNotes,
  573. })
  574. if err != nil {
  575. data, loadErr := u.loadBuildNewPageData(r, pageData{
  576. Title: "New Build",
  577. Err: err.Error(),
  578. Current: "/builds/new",
  579. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  580. if loadErr != nil {
  581. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  582. return
  583. }
  584. u.render.Render(w, "build_new", data)
  585. return
  586. }
  587. http.Redirect(w, r, fmt.Sprintf("/builds/new?template_id=%d&draft_id=%s&msg=draft+saved", templateID, urlQuery(draft.ID)), http.StatusSeeOther)
  588. }
  589. func (u *UI) AutofillDraft(w http.ResponseWriter, r *http.Request) {
  590. if err := r.ParseForm(); err != nil {
  591. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  592. return
  593. }
  594. form := buildFormInputFromRequest(r)
  595. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  596. fieldValues := parseBuildFieldValues(r)
  597. suggestionState := parseSuggestionStateFromRequest(r)
  598. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  599. if err != nil || templateID <= 0 {
  600. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  601. return
  602. }
  603. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
  604. if err != nil || detail.Manifest == nil {
  605. http.Redirect(w, r, "/builds/new?err=template+detail+missing", http.StatusSeeOther)
  606. return
  607. }
  608. globalData := buildGlobalDataFromForm(form)
  609. draftContext := buildDraftContextFromForm(form, globalData)
  610. action, targetFieldPath := parseAutofillAction(strings.TrimSpace(r.FormValue("autofill_action")))
  611. focusFieldPath := targetFieldPath
  612. now := time.Now().UTC()
  613. activeSettings := u.loadPromptSettings(r.Context())
  614. activeProvider := domain.NormalizeLLMProvider(activeSettings.LLMActiveProvider)
  615. activeModel := domain.NormalizeLLMModel(activeProvider, activeSettings.LLMActiveModel)
  616. autofillStart := time.Now()
  617. u.logger.InfoContext(r.Context(), "autofill action",
  618. "component", "autofill",
  619. "step", "action_start",
  620. "status", "start",
  621. "action", action,
  622. "provider", activeProvider,
  623. "model", activeModel,
  624. "draft_id", strings.TrimSpace(form.DraftID),
  625. "template_id", templateID,
  626. )
  627. req := mapping.SuggestionRequest{
  628. TemplateID: templateID,
  629. DraftID: strings.TrimSpace(form.DraftID),
  630. Fields: detail.Fields,
  631. GlobalData: globalData,
  632. DraftContext: draftContext,
  633. MasterPrompt: form.MasterPrompt,
  634. PromptBlocks: form.PromptBlocks,
  635. Existing: fieldValues,
  636. }
  637. msg := "autofill ready"
  638. switch action {
  639. case "generate_all":
  640. suggestionState = mapping.GenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now)
  641. msg = "suggestions generated"
  642. case "regenerate_all":
  643. suggestionState = mapping.RegenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now)
  644. msg = "suggestions regenerated"
  645. case "apply_all":
  646. fieldValues, suggestionState = mapping.ApplyAllSuggestions(fieldValues, suggestionState, now)
  647. msg = "all suggestions applied"
  648. case "apply_all_empty":
  649. fieldValues, suggestionState = mapping.ApplySuggestionsToEmptyFields(fieldValues, suggestionState, now)
  650. msg = "suggestions applied to empty fields"
  651. case "apply_field":
  652. fieldValues, suggestionState = mapping.ApplySuggestionToField(fieldValues, suggestionState, targetFieldPath, now)
  653. msg = "field suggestion applied"
  654. case "regenerate_field":
  655. suggestionState = mapping.RegenerateFieldSuggestion(r.Context(), u.suggestionGenerator, req, suggestionState, targetFieldPath, now)
  656. msg = "field suggestion regenerated"
  657. default:
  658. msg = "unknown autofill action"
  659. }
  660. u.logger.InfoContext(r.Context(), "autofill state transition",
  661. "component", "autofill",
  662. "step", "pre_action_summary",
  663. "action", action,
  664. "state_count", len(suggestionState.ByFieldPath),
  665. "state_sources", summarizeSuggestionSources(suggestionState),
  666. )
  667. u.logger.DebugContext(r.Context(), "autofill state transition",
  668. "component", "autofill",
  669. "step", "pre_action_summary",
  670. "action", action,
  671. "sample_sources", sampleSuggestionSources(suggestionState, 5),
  672. )
  673. sourceCounts := summarizeSuggestionSources(suggestionState)
  674. u.logger.InfoContext(r.Context(), "autofill action",
  675. "component", "autofill",
  676. "step", "action_finish",
  677. "status", "success",
  678. "action", action,
  679. "provider", activeProvider,
  680. "model", activeModel,
  681. "draft_id", strings.TrimSpace(form.DraftID),
  682. "template_id", templateID,
  683. "suggestion_count", len(suggestionState.ByFieldPath),
  684. "sources", sourceCounts,
  685. "duration_ms", time.Since(autofillStart).Milliseconds(),
  686. )
  687. if strings.TrimSpace(form.DraftID) != "" {
  688. _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  689. DraftID: form.DraftID,
  690. TemplateID: int64Ptr(templateID),
  691. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  692. Source: form.DraftSource,
  693. RequestName: form.RequestName,
  694. GlobalData: globalData,
  695. FieldValues: fieldValues,
  696. DraftContext: draftContext,
  697. SuggestionState: &suggestionState,
  698. Status: defaultDraftStatus(form.DraftStatus),
  699. Notes: form.DraftNotes,
  700. })
  701. }
  702. data, loadErr := u.loadBuildNewPageData(r, pageData{
  703. Title: "New Build",
  704. Msg: msg,
  705. Current: "/builds/new",
  706. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  707. if loadErr != nil {
  708. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  709. return
  710. }
  711. data.AutofillFocusID = fieldAnchorID(focusFieldPath)
  712. u.render.Render(w, "build_new", data)
  713. }
  714. func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) {
  715. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  716. build, err := u.buildSvc.GetBuild(r.Context(), buildID)
  717. if err != nil {
  718. http.Error(w, err.Error(), http.StatusNotFound)
  719. return
  720. }
  721. status := strings.ToLower(strings.TrimSpace(build.QCStatus))
  722. canPoll := status == "queued" || status == "processing"
  723. canFetchEditor := (status == "done" || status == "failed" || status == "timeout") &&
  724. build.QCSiteID != nil &&
  725. strings.TrimSpace(build.QCEditorURL) == ""
  726. autoRefresh := 0
  727. if canPoll && u.cfg.PollIntervalSeconds > 0 {
  728. autoRefresh = u.cfg.PollIntervalSeconds
  729. }
  730. effectiveGlobal := build.GlobalDataJSON
  731. if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 {
  732. effectiveGlobal = payloadGlobal
  733. }
  734. u.render.Render(w, "build_detail", buildDetailPageData{
  735. pageData: basePageData(r, "Build Detail", "/builds"),
  736. Build: build,
  737. EffectiveGlobal: effectiveGlobal,
  738. CanPoll: canPoll,
  739. CanFetchEditorURL: canFetchEditor,
  740. AutoRefreshSeconds: autoRefresh,
  741. })
  742. }
  743. func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) {
  744. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  745. if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil {
  746. http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
  747. return
  748. }
  749. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther)
  750. }
  751. func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) {
  752. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  753. if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil {
  754. http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
  755. return
  756. }
  757. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther)
  758. }
  759. func basePageData(r *http.Request, title, current string) pageData {
  760. q := r.URL.Query()
  761. return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current}
  762. }
  763. func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) {
  764. rawID := chi.URLParam(r, "id")
  765. templateID, err := strconv.ParseInt(rawID, 10, 64)
  766. if err != nil {
  767. http.Error(w, "invalid template id", http.StatusBadRequest)
  768. return 0, false
  769. }
  770. return templateID, true
  771. }
  772. func urlQuery(s string) string {
  773. return url.QueryEscape(s)
  774. }
  775. func applyLLMSettingsForm(settings domain.AppSettings, r *http.Request) (domain.AppSettings, error) {
  776. next := settings
  777. next.LLMActiveProvider = domain.NormalizeLLMProvider(r.FormValue("llm_provider"))
  778. next.LLMActiveModel = domain.NormalizeLLMModel(next.LLMActiveProvider, r.FormValue("llm_model"))
  779. next.LLMBaseURL = strings.TrimSpace(r.FormValue("llm_base_url"))
  780. tempRaw := strings.TrimSpace(r.FormValue("llm_temperature"))
  781. if tempRaw == "" {
  782. next.LLMTemperature = domain.NormalizeLLMTemperature(next.LLMTemperature)
  783. } else {
  784. temp, err := strconv.ParseFloat(tempRaw, 64)
  785. if err != nil {
  786. return settings, fmt.Errorf("invalid llm temperature")
  787. }
  788. next.LLMTemperature = domain.NormalizeLLMTemperature(temp)
  789. }
  790. maxTokensRaw := strings.TrimSpace(r.FormValue("llm_max_tokens"))
  791. if maxTokensRaw == "" {
  792. next.LLMMaxTokens = domain.NormalizeLLMMaxTokens(next.LLMMaxTokens)
  793. } else {
  794. maxTokens, err := strconv.Atoi(maxTokensRaw)
  795. if err != nil {
  796. return settings, fmt.Errorf("invalid llm max tokens")
  797. }
  798. next.LLMMaxTokens = domain.NormalizeLLMMaxTokens(maxTokens)
  799. }
  800. if value := strings.TrimSpace(r.FormValue("llm_api_key_openai")); value != "" {
  801. next.OpenAIAPIKeyEncrypted = value
  802. }
  803. if value := strings.TrimSpace(r.FormValue("llm_api_key_anthropic")); value != "" {
  804. next.AnthropicAPIKeyEncrypted = value
  805. }
  806. if value := strings.TrimSpace(r.FormValue("llm_api_key_google")); value != "" {
  807. next.GoogleAPIKeyEncrypted = value
  808. }
  809. if value := strings.TrimSpace(r.FormValue("llm_api_key_xai")); value != "" {
  810. next.XAIAPIKeyEncrypted = value
  811. }
  812. if value := strings.TrimSpace(r.FormValue("llm_api_key_ollama")); value != "" {
  813. next.OllamaAPIKeyEncrypted = value
  814. }
  815. if value := strings.TrimSpace(r.FormValue("google_places_api_key")); value != "" {
  816. next.GooglePlacesAPIKeyEncrypted = value
  817. }
  818. return next, nil
  819. }
  820. func validateLLMProviderConfig(ctx context.Context, settings domain.AppSettings, logger *slog.Logger) error {
  821. if logger == nil {
  822. logger = slog.Default()
  823. }
  824. started := time.Now()
  825. provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
  826. model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
  827. baseURL := strings.TrimSpace(settings.LLMBaseURL)
  828. logger.InfoContext(ctx, "validate llm provider config",
  829. "component", "autofill",
  830. "step", "validate_provider",
  831. "status", "start",
  832. "provider", provider,
  833. "model", model,
  834. "base_url", baseURL,
  835. )
  836. if strings.TrimSpace(model) == "" {
  837. logger.WarnContext(ctx, "validate llm provider config",
  838. "component", "autofill",
  839. "step", "validate_provider",
  840. "status", "failed",
  841. "provider", provider,
  842. "model", model,
  843. "base_url", baseURL,
  844. "error", "no active model configured",
  845. "duration_ms", time.Since(started).Milliseconds(),
  846. )
  847. return fmt.Errorf("no active model configured")
  848. }
  849. if baseURL != "" {
  850. parsed, err := url.Parse(baseURL)
  851. if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" {
  852. logger.WarnContext(ctx, "validate llm provider config",
  853. "component", "autofill",
  854. "step", "validate_provider",
  855. "status", "failed",
  856. "provider", provider,
  857. "model", model,
  858. "base_url", baseURL,
  859. "error", "invalid llm base url",
  860. "duration_ms", time.Since(started).Milliseconds(),
  861. )
  862. return fmt.Errorf("invalid llm base url")
  863. }
  864. }
  865. apiKey := domain.LLMAPIKeyForProvider(provider, settings)
  866. if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" {
  867. logger.WarnContext(ctx, "validate llm provider config",
  868. "component", "autofill",
  869. "step", "validate_provider",
  870. "status", "failed",
  871. "provider", provider,
  872. "model", model,
  873. "base_url", baseURL,
  874. "error", "missing api key",
  875. "duration_ms", time.Since(started).Milliseconds(),
  876. )
  877. return fmt.Errorf("api key for provider %s is not configured", provider)
  878. }
  879. runtimeFactory := llmruntime.NewFactory(10 * time.Second)
  880. client, err := runtimeFactory.ClientFor(provider)
  881. if err != nil {
  882. logger.WarnContext(ctx, "validate llm provider config",
  883. "component", "autofill",
  884. "step", "validate_provider",
  885. "status", "failed",
  886. "provider", provider,
  887. "model", model,
  888. "base_url", baseURL,
  889. "error", shortError(err),
  890. "duration_ms", time.Since(started).Milliseconds(),
  891. )
  892. return err
  893. }
  894. temperature := domain.NormalizeLLMTemperature(settings.LLMTemperature)
  895. maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens)
  896. validationTokens := maxTokens
  897. if validationTokens > 64 {
  898. validationTokens = 64
  899. }
  900. if validationTokens < 16 {
  901. validationTokens = 16
  902. }
  903. resp, err := client.Generate(ctx, llmruntime.Request{
  904. Provider: provider,
  905. Model: model,
  906. BaseURL: baseURL,
  907. APIKey: apiKey,
  908. Temperature: &temperature,
  909. MaxTokens: &validationTokens,
  910. SystemPrompt: "You validate LLM connectivity for settings. Answer with plain text OK.",
  911. UserPrompt: "Return OK",
  912. })
  913. if err != nil {
  914. logger.WarnContext(ctx, "validate llm provider config",
  915. "component", "autofill",
  916. "step", "validate_provider",
  917. "status", "failed",
  918. "provider", provider,
  919. "model", model,
  920. "base_url", baseURL,
  921. "error", shortError(err),
  922. "duration_ms", time.Since(started).Milliseconds(),
  923. )
  924. return fmt.Errorf("provider validation failed (%s/%s): %w", provider, model, err)
  925. }
  926. if strings.TrimSpace(resp) == "" {
  927. logger.WarnContext(ctx, "validate llm provider config",
  928. "component", "autofill",
  929. "step", "validate_provider",
  930. "status", "failed",
  931. "provider", provider,
  932. "model", model,
  933. "base_url", baseURL,
  934. "error", "empty response",
  935. "duration_ms", time.Since(started).Milliseconds(),
  936. )
  937. return fmt.Errorf("provider validation failed (%s/%s): empty response", provider, model)
  938. }
  939. logger.InfoContext(ctx, "validate llm provider config",
  940. "component", "autofill",
  941. "step", "validate_provider",
  942. "status", "success",
  943. "provider", provider,
  944. "model", model,
  945. "base_url", baseURL,
  946. "duration_ms", time.Since(started).Milliseconds(),
  947. )
  948. logger.DebugContext(ctx, "validate llm provider config",
  949. "component", "autofill",
  950. "step", "validate_provider",
  951. "status", "success",
  952. "provider", provider,
  953. "model", model,
  954. "base_url", baseURL,
  955. "response_snippet", trimSnippet(resp, 40),
  956. )
  957. return nil
  958. }
  959. func boolPtr(v bool) *bool { return &v }
  960. func intPtr(v int) *int { return &v }
  961. func int64Ptr(v int64) *int64 { return &v }
  962. func strPtr(v string) *string {
  963. return &v
  964. }
  965. func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string, suggestionState domain.DraftSuggestionState) (buildNewPageData, error) {
  966. if strings.TrimSpace(form.MasterPrompt) == "" {
  967. form.MasterPrompt = domain.SeedMasterPrompt
  968. }
  969. form.PromptBlocks = domain.NormalizePromptBlocks(form.PromptBlocks)
  970. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  971. templates, err := u.templateSvc.ListTemplates(r.Context())
  972. if err != nil {
  973. return buildNewPageData{}, err
  974. }
  975. drafts, err := u.draftSvc.ListDrafts(r.Context(), 50)
  976. if err != nil {
  977. return buildNewPageData{}, err
  978. }
  979. data := buildNewPageData{
  980. pageData: page,
  981. Templates: templates,
  982. Drafts: drafts,
  983. SelectedDraftID: selectedDraftID,
  984. SelectedTemplateID: selectedTemplateID,
  985. SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState),
  986. ShowDebug: parseDebugMode(r),
  987. Form: form,
  988. }
  989. if selectedTemplateID <= 0 {
  990. return data, nil
  991. }
  992. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID)
  993. if err != nil || detail.Manifest == nil {
  994. return data, nil
  995. }
  996. data.SelectedManifestID = detail.Manifest.ID
  997. data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues, suggestionState.ByFieldPath)
  998. data.EnabledFields = data.EditableFields
  999. data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields))
  1000. return data, nil
  1001. }
  1002. func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormInput) buildFormInput {
  1003. settings := u.loadPromptSettings(ctx)
  1004. form.MasterPrompt = settings.MasterPrompt
  1005. form.PromptBlocks = clonePromptBlocks(settings.PromptBlocks)
  1006. if strings.TrimSpace(form.DraftID) == "" {
  1007. return form
  1008. }
  1009. draft, err := u.draftSvc.GetDraft(ctx, strings.TrimSpace(form.DraftID))
  1010. if err != nil || draft == nil {
  1011. return form
  1012. }
  1013. mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
  1014. form.MasterPrompt = settings.MasterPrompt
  1015. form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
  1016. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  1017. return form
  1018. }
  1019. func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string, suggestions map[string]domain.DraftSuggestion) ([]buildFieldView, []buildFieldSectionView) {
  1020. sectionOrder := []string{
  1021. domain.WebsiteSectionHero,
  1022. domain.WebsiteSectionIntro,
  1023. domain.WebsiteSectionServices,
  1024. domain.WebsiteSectionAbout,
  1025. domain.WebsiteSectionTeam,
  1026. domain.WebsiteSectionTestimonials,
  1027. domain.WebsiteSectionCTA,
  1028. domain.WebsiteSectionContact,
  1029. domain.WebsiteSectionGallery,
  1030. domain.WebsiteSectionFooter,
  1031. domain.WebsiteSectionOther,
  1032. }
  1033. sectionDescriptions := map[string]string{
  1034. domain.WebsiteSectionHero: "Headline-nahe Felder, bevorzugt nach Block-ID gruppiert.",
  1035. domain.WebsiteSectionIntro: "Intro-/Einleitungs-Felder, bevorzugt nach Block-ID gruppiert.",
  1036. domain.WebsiteSectionServices: "Services-Felder, bevorzugt nach Block-ID gruppiert.",
  1037. domain.WebsiteSectionAbout: "About-Felder, bevorzugt nach Block-ID gruppiert.",
  1038. domain.WebsiteSectionTeam: "Team-Felder, bevorzugt nach Block-ID gruppiert.",
  1039. domain.WebsiteSectionTestimonials: "Testimonial-Felder, bevorzugt nach Block-ID gruppiert.",
  1040. domain.WebsiteSectionCTA: "CTA-/Highlight-Felder, bevorzugt nach Block-ID gruppiert.",
  1041. domain.WebsiteSectionContact: "Kontakt-Felder, bevorzugt nach Block-ID gruppiert.",
  1042. domain.WebsiteSectionGallery: "Media/Gallery-Felder, Bildfelder bleiben im MVP nicht editierbar.",
  1043. domain.WebsiteSectionFooter: "Footer-Felder, bevorzugt nach Block-ID gruppiert.",
  1044. domain.WebsiteSectionOther: "Aktive Textfelder ausserhalb der Kern-Sections, bevorzugt nach Block-ID gruppiert.",
  1045. }
  1046. sectionsByKey := make(map[string]buildFieldSectionView, len(sectionOrder))
  1047. pendingByKey := make(map[string][]pendingField, len(sectionOrder))
  1048. for _, key := range sectionOrder {
  1049. sectionsByKey[key] = buildFieldSectionView{
  1050. Key: key,
  1051. Title: domain.WebsiteSectionLabel(key),
  1052. Description: sectionDescriptions[key],
  1053. }
  1054. }
  1055. for _, f := range fields {
  1056. targetSection := preferredBuildSection(f)
  1057. if isMediaOrGalleryField(f) || targetSection == domain.WebsiteSectionGallery {
  1058. labelFallback := domain.WebsiteSectionLabel(domain.WebsiteSectionGallery) + " - " + humanizeKey(f.KeyName)
  1059. if blockID := extractBlockID(f); blockID != "" {
  1060. labelFallback = "Media - " + blockGroupTitle(blockID)
  1061. }
  1062. media := sectionsByKey[domain.WebsiteSectionGallery]
  1063. media.DisabledFields = append(media.DisabledFields, buildFieldView{
  1064. AnchorID: fieldAnchorID(f.Path),
  1065. Path: f.Path,
  1066. DisplayLabel: effectiveLabel(f, labelFallback),
  1067. SampleValue: f.SampleValue,
  1068. Value: "",
  1069. })
  1070. sectionsByKey[domain.WebsiteSectionGallery] = media
  1071. continue
  1072. }
  1073. if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
  1074. continue
  1075. }
  1076. suggestion := suggestions[f.Path]
  1077. pf := pendingField{
  1078. Field: f,
  1079. View: buildFieldView{
  1080. AnchorID: fieldAnchorID(f.Path),
  1081. Path: f.Path,
  1082. DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)),
  1083. SampleValue: f.SampleValue,
  1084. Value: strings.TrimSpace(fieldValues[f.Path]),
  1085. SuggestedValue: strings.TrimSpace(suggestion.Value),
  1086. SuggestionReason: strings.TrimSpace(suggestion.Reason),
  1087. SuggestionStatus: strings.TrimSpace(suggestion.Status),
  1088. SuggestionSource: strings.TrimSpace(suggestion.Source),
  1089. },
  1090. }
  1091. pendingByKey[targetSection] = append(pendingByKey[targetSection], pf)
  1092. }
  1093. for _, key := range sectionOrder {
  1094. section := sectionsByKey[key]
  1095. items := pendingByKey[key]
  1096. switch key {
  1097. case domain.WebsiteSectionServices:
  1098. section = applyServicesGrouping(section, items)
  1099. case domain.WebsiteSectionTestimonials:
  1100. section = applyTestimonialsGrouping(section, items)
  1101. case domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionFooter:
  1102. section = applyTextGrouping(section, items)
  1103. case domain.WebsiteSectionOther:
  1104. section = applyOtherGrouping(section, items)
  1105. case domain.WebsiteSectionGallery:
  1106. // Gallery fields are handled as disabled entries only in this MVP.
  1107. default:
  1108. section = applyOtherGrouping(section, items)
  1109. }
  1110. sectionsByKey[key] = section
  1111. }
  1112. sections := make([]buildFieldSectionView, 0, len(sectionOrder))
  1113. for _, key := range sectionOrder {
  1114. sections = append(sections, sectionsByKey[key])
  1115. }
  1116. return assignEditableIndexes(sections)
  1117. }
  1118. func applyServicesGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  1119. return applyBlockFirstGrouping(section, fields, "Services", applyServicesGroupingFallback)
  1120. }
  1121. func applyServicesGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  1122. titles := make([]pendingField, 0)
  1123. descriptions := make([]pendingField, 0)
  1124. other := make([]buildFieldView, 0)
  1125. for _, pf := range fields {
  1126. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  1127. switch {
  1128. case strings.HasPrefix(key, "servicestitle_") || strings.HasPrefix(key, "servicestitle"):
  1129. titles = append(titles, pf)
  1130. case strings.HasPrefix(key, "servicesdescription_") || strings.HasPrefix(key, "servicesdescription"):
  1131. descriptions = append(descriptions, pf)
  1132. default:
  1133. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Services - "+humanizeKey(pf.Field.KeyName))
  1134. other = append(other, pf.View)
  1135. }
  1136. }
  1137. maxCount := len(titles)
  1138. if len(descriptions) > maxCount {
  1139. maxCount = len(descriptions)
  1140. }
  1141. for i := 0; i < maxCount; i++ {
  1142. block := buildFieldGroupView{Title: fmt.Sprintf("Service %d", i+1)}
  1143. if i < len(titles) {
  1144. item := titles[i]
  1145. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Titel", i+1))
  1146. block.Fields = append(block.Fields, item.View)
  1147. }
  1148. if i < len(descriptions) {
  1149. item := descriptions[i]
  1150. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Beschreibung", i+1))
  1151. block.Fields = append(block.Fields, item.View)
  1152. }
  1153. if len(block.Fields) > 0 {
  1154. section.EditableGroups = append(section.EditableGroups, block)
  1155. }
  1156. }
  1157. section.EditableFields = append(section.EditableFields, other...)
  1158. return section
  1159. }
  1160. func applyTestimonialsGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  1161. return applyBlockFirstGrouping(section, fields, "Testimonials", applyTestimonialsGroupingFallback)
  1162. }
  1163. func applyTestimonialsGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  1164. names := make([]pendingField, 0)
  1165. titles := make([]pendingField, 0)
  1166. descriptions := make([]pendingField, 0)
  1167. other := make([]buildFieldView, 0)
  1168. for _, pf := range fields {
  1169. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  1170. switch {
  1171. case strings.HasPrefix(key, "testimonialsname_") || strings.HasPrefix(key, "testimonialsname"):
  1172. names = append(names, pf)
  1173. case strings.HasPrefix(key, "testimonialstitle_") || strings.HasPrefix(key, "testimonialstitle"):
  1174. titles = append(titles, pf)
  1175. case strings.HasPrefix(key, "testimonialsdescription_") || strings.HasPrefix(key, "testimonialsdescription"):
  1176. descriptions = append(descriptions, pf)
  1177. default:
  1178. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Testimonials - "+humanizeKey(pf.Field.KeyName))
  1179. other = append(other, pf.View)
  1180. }
  1181. }
  1182. maxCount := len(names)
  1183. if len(titles) > maxCount {
  1184. maxCount = len(titles)
  1185. }
  1186. if len(descriptions) > maxCount {
  1187. maxCount = len(descriptions)
  1188. }
  1189. for i := 0; i < maxCount; i++ {
  1190. block := buildFieldGroupView{Title: fmt.Sprintf("Testimonial %d", i+1)}
  1191. if i < len(names) {
  1192. item := names[i]
  1193. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Name", i+1))
  1194. block.Fields = append(block.Fields, item.View)
  1195. }
  1196. if i < len(titles) {
  1197. item := titles[i]
  1198. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Titel", i+1))
  1199. block.Fields = append(block.Fields, item.View)
  1200. }
  1201. if i < len(descriptions) {
  1202. item := descriptions[i]
  1203. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Beschreibung", i+1))
  1204. block.Fields = append(block.Fields, item.View)
  1205. }
  1206. if len(block.Fields) > 0 {
  1207. section.EditableGroups = append(section.EditableGroups, block)
  1208. }
  1209. }
  1210. section.EditableFields = append(section.EditableFields, other...)
  1211. return section
  1212. }
  1213. func applyTextGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  1214. return applyBlockFirstGrouping(section, fields, "Text", applyTextGroupingFallback)
  1215. }
  1216. func applyTextGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  1217. titles := make([]pendingField, 0)
  1218. descriptions := make([]pendingField, 0)
  1219. names := make([]pendingField, 0)
  1220. other := make([]buildFieldView, 0)
  1221. for _, pf := range fields {
  1222. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  1223. switch {
  1224. case strings.HasPrefix(key, "texttitle_") || strings.HasPrefix(key, "texttitle") || strings.HasPrefix(key, "exttitle_") || strings.HasPrefix(key, "exttitle"):
  1225. titles = append(titles, pf)
  1226. case strings.HasPrefix(key, "textdescription_") || strings.HasPrefix(key, "textdescription") || strings.HasPrefix(key, "extdescription_") || strings.HasPrefix(key, "extdescription"):
  1227. descriptions = append(descriptions, pf)
  1228. case strings.HasPrefix(key, "textname_") || strings.HasPrefix(key, "textname") || strings.HasPrefix(key, "extname_") || strings.HasPrefix(key, "extname"):
  1229. names = append(names, pf)
  1230. default:
  1231. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Text - "+humanizeKey(pf.Field.KeyName))
  1232. other = append(other, pf.View)
  1233. }
  1234. }
  1235. maxCount := len(titles)
  1236. if len(descriptions) > maxCount {
  1237. maxCount = len(descriptions)
  1238. }
  1239. if len(names) > maxCount {
  1240. maxCount = len(names)
  1241. }
  1242. for i := 0; i < maxCount; i++ {
  1243. block := buildFieldGroupView{Title: fmt.Sprintf("Textblock %d", i+1)}
  1244. if i < len(titles) {
  1245. item := titles[i]
  1246. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Titel", i+1))
  1247. block.Fields = append(block.Fields, item.View)
  1248. }
  1249. if i < len(descriptions) {
  1250. item := descriptions[i]
  1251. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Beschreibung", i+1))
  1252. block.Fields = append(block.Fields, item.View)
  1253. }
  1254. if i < len(names) {
  1255. item := names[i]
  1256. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Name", i+1))
  1257. block.Fields = append(block.Fields, item.View)
  1258. }
  1259. if len(block.Fields) > 0 {
  1260. section.EditableGroups = append(section.EditableGroups, block)
  1261. }
  1262. }
  1263. section.EditableFields = append(section.EditableFields, other...)
  1264. return section
  1265. }
  1266. func applyOtherGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  1267. return applyBlockFirstGrouping(section, fields, "", applyOtherGroupingFallback)
  1268. }
  1269. func applyOtherGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  1270. for _, pf := range fields {
  1271. pf.View.DisplayLabel = effectiveLabel(pf.Field, normalizedSectionTitle(pf.Field.Section)+" - "+humanizeKey(pf.Field.KeyName))
  1272. section.EditableFields = append(section.EditableFields, pf.View)
  1273. }
  1274. sort.SliceStable(section.EditableFields, func(i, j int) bool {
  1275. return section.EditableFields[i].Path < section.EditableFields[j].Path
  1276. })
  1277. return section
  1278. }
  1279. func applyBlockFirstGrouping(section buildFieldSectionView, fields []pendingField, fallbackPrefix string, fallback func(buildFieldSectionView, []pendingField) buildFieldSectionView) buildFieldSectionView {
  1280. grouped := map[string][]pendingField{}
  1281. withoutBlockID := make([]pendingField, 0)
  1282. for _, pf := range fields {
  1283. blockID := extractBlockID(pf.Field)
  1284. if blockID == "" {
  1285. withoutBlockID = append(withoutBlockID, pf)
  1286. continue
  1287. }
  1288. grouped[blockID] = append(grouped[blockID], pf)
  1289. }
  1290. if len(grouped) > 0 {
  1291. blockIDs := make([]string, 0, len(grouped))
  1292. for blockID := range grouped {
  1293. blockIDs = append(blockIDs, blockID)
  1294. }
  1295. sort.SliceStable(blockIDs, func(i, j int) bool {
  1296. li, lj := blockSortRank(blockIDs[i]), blockSortRank(blockIDs[j])
  1297. if li != lj {
  1298. return li < lj
  1299. }
  1300. return blockIDs[i] < blockIDs[j]
  1301. })
  1302. for _, blockID := range blockIDs {
  1303. items := grouped[blockID]
  1304. sort.SliceStable(items, func(i, j int) bool {
  1305. ri := deriveFieldRole(items[i].Field.KeyName)
  1306. rj := deriveFieldRole(items[j].Field.KeyName)
  1307. if ri.Order != rj.Order {
  1308. return ri.Order < rj.Order
  1309. }
  1310. return items[i].Field.Path < items[j].Field.Path
  1311. })
  1312. group := buildFieldGroupView{Title: blockGroupTitle(blockID)}
  1313. for _, item := range items {
  1314. role := deriveFieldRole(item.Field.KeyName)
  1315. fallbackLabel := role.Label
  1316. if fallbackLabel == "" {
  1317. fallbackLabel = humanizeKey(item.Field.KeyName)
  1318. }
  1319. item.View.DisplayLabel = effectiveLabel(item.Field, fallbackLabel)
  1320. group.Fields = append(group.Fields, item.View)
  1321. }
  1322. if len(group.Fields) > 0 {
  1323. section.EditableGroups = append(section.EditableGroups, group)
  1324. }
  1325. }
  1326. }
  1327. if len(withoutBlockID) > 0 {
  1328. if fallback != nil {
  1329. return fallback(section, withoutBlockID)
  1330. }
  1331. for _, pf := range withoutBlockID {
  1332. labelPrefix := fallbackPrefix
  1333. if labelPrefix == "" {
  1334. labelPrefix = normalizedSectionTitle(pf.Field.Section)
  1335. }
  1336. pf.View.DisplayLabel = effectiveLabel(pf.Field, labelPrefix+" - "+humanizeKey(pf.Field.KeyName))
  1337. section.EditableFields = append(section.EditableFields, pf.View)
  1338. }
  1339. }
  1340. return section
  1341. }
  1342. func extractBlockID(f domain.TemplateField) string {
  1343. candidates := []string{f.KeyName, f.Path}
  1344. for _, candidate := range candidates {
  1345. normalized := strings.ToLower(strings.TrimSpace(candidate))
  1346. if normalized == "" {
  1347. continue
  1348. }
  1349. if match := blockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
  1350. return match[1]
  1351. }
  1352. if match := looseBlockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
  1353. return match[1]
  1354. }
  1355. }
  1356. return ""
  1357. }
  1358. func blockGroupTitle(blockID string) string {
  1359. blockID = strings.ToLower(strings.TrimSpace(blockID))
  1360. if blockID == "" {
  1361. return "Unbekannter Block"
  1362. }
  1363. if known, ok := knownBlockAreas[blockID]; ok {
  1364. return fmt.Sprintf("%s (%s)", known, blockID)
  1365. }
  1366. return "Block " + blockID
  1367. }
  1368. func blockSortRank(blockID string) int {
  1369. switch strings.ToLower(strings.TrimSpace(blockID)) {
  1370. case "m1710":
  1371. return 10
  1372. case "c7886":
  1373. return 20
  1374. case "r4830":
  1375. return 30
  1376. case "c2929":
  1377. return 40
  1378. case "r4748":
  1379. return 50
  1380. case "r1508":
  1381. return 60
  1382. case "c1165":
  1383. return 70
  1384. case "m4178":
  1385. return 80
  1386. default:
  1387. return 1000
  1388. }
  1389. }
  1390. func deriveFieldRole(key string) fieldRole {
  1391. normalized := strings.ToLower(strings.TrimSpace(key))
  1392. switch {
  1393. case strings.Contains(normalized, "subtitle"):
  1394. return fieldRole{Label: "Untertitel", Order: 15}
  1395. case strings.Contains(normalized, "title"):
  1396. return fieldRole{Label: "Titel", Order: 20}
  1397. case strings.Contains(normalized, "description"):
  1398. return fieldRole{Label: "Beschreibung", Order: 30}
  1399. case strings.Contains(normalized, "name"):
  1400. return fieldRole{Label: "Name", Order: 40}
  1401. case strings.Contains(normalized, "button") || strings.Contains(normalized, "cta"):
  1402. return fieldRole{Label: "CTA Text", Order: 50}
  1403. default:
  1404. return fieldRole{Label: humanizeKey(key), Order: 100}
  1405. }
  1406. }
  1407. func assignEditableIndexes(sections []buildFieldSectionView) ([]buildFieldView, []buildFieldSectionView) {
  1408. editable := make([]buildFieldView, 0)
  1409. nextIndex := 0
  1410. for si := range sections {
  1411. for gi := range sections[si].EditableGroups {
  1412. for fi := range sections[si].EditableGroups[gi].Fields {
  1413. sections[si].EditableGroups[gi].Fields[fi].Index = nextIndex
  1414. editable = append(editable, sections[si].EditableGroups[gi].Fields[fi])
  1415. nextIndex++
  1416. }
  1417. }
  1418. for fi := range sections[si].EditableFields {
  1419. sections[si].EditableFields[fi].Index = nextIndex
  1420. editable = append(editable, sections[si].EditableFields[fi])
  1421. nextIndex++
  1422. }
  1423. }
  1424. return editable, sections
  1425. }
  1426. func preferredBuildSection(f domain.TemplateField) string {
  1427. websiteSection := strings.TrimSpace(f.WebsiteSection)
  1428. if websiteSection != "" {
  1429. normalized := domain.NormalizeWebsiteSection(websiteSection)
  1430. if normalized == domain.WebsiteSectionServiceItem {
  1431. return domain.WebsiteSectionServices
  1432. }
  1433. return normalized
  1434. }
  1435. return fallbackBuildSection(f)
  1436. }
  1437. func fallbackBuildSection(f domain.TemplateField) string {
  1438. switch normalizedSection(f.Section) {
  1439. case "services":
  1440. return domain.WebsiteSectionServices
  1441. case "testimonials":
  1442. return domain.WebsiteSectionTestimonials
  1443. case "text":
  1444. if isMediaOrGalleryField(f) {
  1445. return domain.WebsiteSectionGallery
  1446. }
  1447. return domain.WebsiteSectionOther
  1448. default:
  1449. if isMediaOrGalleryField(f) {
  1450. return domain.WebsiteSectionGallery
  1451. }
  1452. return domain.WebsiteSectionOther
  1453. }
  1454. }
  1455. func normalizedSection(raw string) string {
  1456. section := strings.ToLower(strings.TrimSpace(raw))
  1457. switch section {
  1458. case "ext":
  1459. return "text"
  1460. default:
  1461. return section
  1462. }
  1463. }
  1464. func normalizedSectionTitle(raw string) string {
  1465. switch normalizedSection(raw) {
  1466. case "text":
  1467. return "Text"
  1468. case "services":
  1469. return "Services"
  1470. case "testimonials":
  1471. return "Testimonials"
  1472. case "gallery", "media":
  1473. return "Media"
  1474. default:
  1475. return "Feld"
  1476. }
  1477. }
  1478. func isMediaOrGalleryField(f domain.TemplateField) bool {
  1479. if strings.EqualFold(strings.TrimSpace(f.FieldKind), "image") {
  1480. return true
  1481. }
  1482. section := strings.ToLower(strings.TrimSpace(f.Section))
  1483. key := strings.ToLower(strings.TrimSpace(f.KeyName))
  1484. path := strings.ToLower(strings.TrimSpace(f.Path))
  1485. if section == "gallery" || section == "media" {
  1486. return true
  1487. }
  1488. hints := []string{"gallery", "image", "img", "photo", "picture"}
  1489. for _, hint := range hints {
  1490. if strings.Contains(section, hint) || strings.Contains(key, hint) || strings.Contains(path, hint) {
  1491. return true
  1492. }
  1493. }
  1494. return false
  1495. }
  1496. func effectiveLabel(f domain.TemplateField, fallback string) string {
  1497. if !isRawPathLikeLabel(f.DisplayLabel, f.Path) {
  1498. return strings.TrimSpace(f.DisplayLabel)
  1499. }
  1500. return strings.TrimSpace(fallback)
  1501. }
  1502. func isRawPathLikeLabel(label string, path string) bool {
  1503. l := strings.TrimSpace(label)
  1504. if l == "" {
  1505. return true
  1506. }
  1507. if strings.EqualFold(l, strings.TrimSpace(path)) {
  1508. return true
  1509. }
  1510. if strings.Contains(l, ".") || strings.Contains(l, "_") {
  1511. return true
  1512. }
  1513. return false
  1514. }
  1515. func humanizeKey(key string) string {
  1516. raw := strings.TrimSpace(key)
  1517. if raw == "" {
  1518. return "Inhalt"
  1519. }
  1520. base := raw
  1521. if idx := strings.Index(base, "_"); idx > 0 {
  1522. base = base[:idx]
  1523. }
  1524. runes := make([]rune, 0, len(base)+4)
  1525. for i, r := range base {
  1526. if i > 0 && unicode.IsUpper(r) {
  1527. runes = append(runes, ' ')
  1528. }
  1529. runes = append(runes, r)
  1530. }
  1531. human := strings.TrimSpace(string(runes))
  1532. if human == "" {
  1533. return "Inhalt"
  1534. }
  1535. words := strings.Fields(strings.ToLower(human))
  1536. for i := range words {
  1537. if len(words[i]) > 0 {
  1538. words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
  1539. }
  1540. }
  1541. return strings.Join(words, " ")
  1542. }
  1543. func buildFormInputFromRequest(r *http.Request) buildFormInput {
  1544. form := buildFormInput{
  1545. DraftID: strings.TrimSpace(r.FormValue("draft_id")),
  1546. DraftSource: strings.TrimSpace(r.FormValue("draft_source")),
  1547. DraftStatus: strings.TrimSpace(r.FormValue("draft_status")),
  1548. DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")),
  1549. RequestName: strings.TrimSpace(r.FormValue("request_name")),
  1550. CompanyName: strings.TrimSpace(r.FormValue("company_name")),
  1551. BusinessType: strings.TrimSpace(r.FormValue("business_type")),
  1552. Username: strings.TrimSpace(r.FormValue("username")),
  1553. Email: strings.TrimSpace(r.FormValue("email")),
  1554. Phone: strings.TrimSpace(r.FormValue("phone")),
  1555. OrgNumber: strings.TrimSpace(r.FormValue("org_number")),
  1556. StartDate: strings.TrimSpace(r.FormValue("start_date")),
  1557. Mission: strings.TrimSpace(r.FormValue("mission")),
  1558. DescriptionShort: strings.TrimSpace(r.FormValue("description_short")),
  1559. DescriptionLong: strings.TrimSpace(r.FormValue("description_long")),
  1560. SiteLanguage: strings.TrimSpace(r.FormValue("site_language")),
  1561. AddressLine1: strings.TrimSpace(r.FormValue("address_line1")),
  1562. AddressLine2: strings.TrimSpace(r.FormValue("address_line2")),
  1563. AddressCity: strings.TrimSpace(r.FormValue("address_city")),
  1564. AddressRegion: strings.TrimSpace(r.FormValue("address_region")),
  1565. AddressZIP: strings.TrimSpace(r.FormValue("address_zip")),
  1566. AddressCountry: strings.TrimSpace(r.FormValue("address_country")),
  1567. WebsiteURL: strings.TrimSpace(r.FormValue("website_url")),
  1568. WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")),
  1569. LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")),
  1570. MarketStyle: strings.TrimSpace(r.FormValue("market_style")),
  1571. AddressMode: strings.TrimSpace(r.FormValue("address_mode")),
  1572. ContentTone: strings.TrimSpace(r.FormValue("content_tone")),
  1573. }
  1574. return form
  1575. }
  1576. func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput {
  1577. form := buildFormInput{
  1578. DraftID: draft.ID,
  1579. DraftSource: draft.Source,
  1580. DraftStatus: draft.Status,
  1581. DraftNotes: draft.Notes,
  1582. RequestName: draft.RequestName,
  1583. }
  1584. mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON)
  1585. mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
  1586. return form
  1587. }
  1588. func parseBuildFieldValues(r *http.Request) map[string]string {
  1589. fieldValues := map[string]string{}
  1590. count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count")))
  1591. for i := 0; i < count; i++ {
  1592. path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
  1593. value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i)))
  1594. if path != "" {
  1595. fieldValues[path] = value
  1596. }
  1597. }
  1598. return fieldValues
  1599. }
  1600. func parseSuggestionStateFromRequest(r *http.Request) domain.DraftSuggestionState {
  1601. return parseSuggestionStateRaw(strings.TrimSpace(r.FormValue("suggestion_state_json")))
  1602. }
  1603. func parseSuggestionStateJSON(raw []byte) domain.DraftSuggestionState {
  1604. return parseSuggestionStateRaw(strings.TrimSpace(string(raw)))
  1605. }
  1606. func parseSuggestionStateRaw(raw string) domain.DraftSuggestionState {
  1607. if strings.TrimSpace(raw) == "" {
  1608. return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
  1609. }
  1610. var state domain.DraftSuggestionState
  1611. if err := json.Unmarshal([]byte(raw), &state); err != nil {
  1612. return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
  1613. }
  1614. if state.ByFieldPath == nil {
  1615. state.ByFieldPath = map[string]domain.DraftSuggestion{}
  1616. }
  1617. return state
  1618. }
  1619. func encodeSuggestionStateJSON(state domain.DraftSuggestionState) string {
  1620. normalized := state
  1621. if normalized.ByFieldPath == nil {
  1622. normalized.ByFieldPath = map[string]domain.DraftSuggestion{}
  1623. }
  1624. raw, err := json.Marshal(normalized)
  1625. if err != nil {
  1626. return `{"byFieldPath":{}}`
  1627. }
  1628. return string(raw)
  1629. }
  1630. func parseAutofillAction(raw string) (string, string) {
  1631. value := strings.TrimSpace(raw)
  1632. if value == "" {
  1633. return "", ""
  1634. }
  1635. parts := strings.SplitN(value, "::", 2)
  1636. action := strings.TrimSpace(parts[0])
  1637. if len(parts) == 1 {
  1638. return action, ""
  1639. }
  1640. return action, strings.TrimSpace(parts[1])
  1641. }
  1642. func parseDebugMode(r *http.Request) bool {
  1643. if r == nil {
  1644. return false
  1645. }
  1646. value := strings.ToLower(strings.TrimSpace(r.FormValue("debug")))
  1647. switch value {
  1648. case "1", "true", "on", "yes":
  1649. return true
  1650. default:
  1651. return false
  1652. }
  1653. }
  1654. func summarizeSuggestionSources(state domain.DraftSuggestionState) map[string]int {
  1655. if len(state.ByFieldPath) == 0 {
  1656. return map[string]int{}
  1657. }
  1658. out := map[string]int{}
  1659. for _, suggestion := range state.ByFieldPath {
  1660. source := strings.TrimSpace(suggestion.Source)
  1661. if source == "" {
  1662. source = "unknown"
  1663. }
  1664. out[source]++
  1665. }
  1666. return out
  1667. }
  1668. func sampleSuggestionSources(state domain.DraftSuggestionState, limit int) map[string]string {
  1669. if limit <= 0 || len(state.ByFieldPath) == 0 {
  1670. return map[string]string{}
  1671. }
  1672. paths := make([]string, 0, len(state.ByFieldPath))
  1673. for path := range state.ByFieldPath {
  1674. paths = append(paths, path)
  1675. }
  1676. sort.Strings(paths)
  1677. if len(paths) > limit {
  1678. paths = paths[:limit]
  1679. }
  1680. out := make(map[string]string, len(paths))
  1681. for _, path := range paths {
  1682. source := strings.TrimSpace(state.ByFieldPath[path].Source)
  1683. if source == "" {
  1684. source = "unknown"
  1685. }
  1686. out[path] = source
  1687. }
  1688. return out
  1689. }
  1690. func shortError(err error) string {
  1691. if err == nil {
  1692. return ""
  1693. }
  1694. message := strings.TrimSpace(err.Error())
  1695. if len(message) > 180 {
  1696. return message[:180] + "..."
  1697. }
  1698. return message
  1699. }
  1700. func trimSnippet(value string, max int) string {
  1701. trimmed := strings.TrimSpace(value)
  1702. if max <= 0 || len(trimmed) <= max {
  1703. return trimmed
  1704. }
  1705. return trimmed[:max] + "..."
  1706. }
  1707. func fieldAnchorID(fieldPath string) string {
  1708. path := strings.TrimSpace(strings.ToLower(fieldPath))
  1709. if path == "" {
  1710. return ""
  1711. }
  1712. var b strings.Builder
  1713. b.Grow(len(path) + len("field-"))
  1714. b.WriteString("field-")
  1715. lastDash := false
  1716. for _, r := range path {
  1717. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
  1718. b.WriteRune(r)
  1719. lastDash = false
  1720. continue
  1721. }
  1722. if !lastDash {
  1723. b.WriteByte('-')
  1724. lastDash = true
  1725. }
  1726. }
  1727. out := strings.Trim(b.String(), "-")
  1728. if out == "" || out == "field" {
  1729. return "field-anchor"
  1730. }
  1731. return out
  1732. }
  1733. func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) {
  1734. if len(raw) == 0 {
  1735. return nil, nil
  1736. }
  1737. var payload struct {
  1738. GlobalData map[string]any `json:"globalData"`
  1739. }
  1740. if err := json.Unmarshal(raw, &payload); err != nil {
  1741. return nil, err
  1742. }
  1743. if len(payload.GlobalData) == 0 {
  1744. return nil, nil
  1745. }
  1746. data, err := json.Marshal(payload.GlobalData)
  1747. if err != nil {
  1748. return nil, err
  1749. }
  1750. return data, nil
  1751. }
  1752. func parseFieldValuesJSON(raw []byte) map[string]string {
  1753. out := map[string]string{}
  1754. if len(raw) == 0 {
  1755. return out
  1756. }
  1757. _ = json.Unmarshal(raw, &out)
  1758. return out
  1759. }
  1760. func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) {
  1761. if form == nil || len(raw) == 0 {
  1762. return
  1763. }
  1764. var global map[string]any
  1765. if err := json.Unmarshal(raw, &global); err != nil {
  1766. return
  1767. }
  1768. form.CompanyName = getString(global["companyName"])
  1769. form.BusinessType = getString(global["businessType"])
  1770. form.Username = getString(global["username"])
  1771. form.Email = getString(global["email"])
  1772. form.Phone = getString(global["phone"])
  1773. form.OrgNumber = getString(global["orgNumber"])
  1774. form.StartDate = getString(global["startDate"])
  1775. form.Mission = getString(global["mission"])
  1776. form.DescriptionShort = getString(global["descriptionShort"])
  1777. form.DescriptionLong = getString(global["descriptionLong"])
  1778. form.SiteLanguage = getString(global["siteLanguage"])
  1779. address, _ := global["address"].(map[string]any)
  1780. form.AddressLine1 = getString(address["line1"])
  1781. form.AddressLine2 = getString(address["line2"])
  1782. form.AddressCity = getString(address["city"])
  1783. form.AddressRegion = getString(address["region"])
  1784. form.AddressZIP = getString(address["zip"])
  1785. form.AddressCountry = getString(address["country"])
  1786. }
  1787. func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) {
  1788. if form == nil || len(raw) == 0 {
  1789. return
  1790. }
  1791. var ctx domain.DraftContext
  1792. if err := json.Unmarshal(raw, &ctx); err != nil {
  1793. return
  1794. }
  1795. if strings.TrimSpace(form.BusinessType) == "" {
  1796. form.BusinessType = strings.TrimSpace(ctx.LLM.BusinessType)
  1797. }
  1798. form.WebsiteURL = strings.TrimSpace(ctx.LLM.WebsiteURL)
  1799. form.WebsiteSummary = strings.TrimSpace(ctx.LLM.WebsiteSummary)
  1800. form.LocaleStyle = strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle)
  1801. form.MarketStyle = strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle)
  1802. form.AddressMode = strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode)
  1803. form.ContentTone = strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone)
  1804. form.PromptInstructions = strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions)
  1805. form.PromptBlocks = clonePromptBlocks(ctx.LLM.Prompt.Blocks)
  1806. }
  1807. func buildGlobalDataFromForm(form buildFormInput) map[string]any {
  1808. return buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  1809. CompanyName: form.CompanyName,
  1810. BusinessType: form.BusinessType,
  1811. Username: form.Username,
  1812. Email: form.Email,
  1813. Phone: form.Phone,
  1814. OrgNumber: form.OrgNumber,
  1815. StartDate: form.StartDate,
  1816. Mission: form.Mission,
  1817. DescriptionShort: form.DescriptionShort,
  1818. DescriptionLong: form.DescriptionLong,
  1819. SiteLanguage: form.SiteLanguage,
  1820. AddressLine1: form.AddressLine1,
  1821. AddressLine2: form.AddressLine2,
  1822. AddressCity: form.AddressCity,
  1823. AddressRegion: form.AddressRegion,
  1824. AddressZIP: form.AddressZIP,
  1825. AddressCountry: form.AddressCountry,
  1826. })
  1827. }
  1828. func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext {
  1829. businessType := strings.TrimSpace(form.BusinessType)
  1830. if businessType == "" {
  1831. businessType = strings.TrimSpace(getString(globalData["businessType"]))
  1832. }
  1833. return &domain.DraftContext{
  1834. IntakeSource: strings.TrimSpace(form.DraftSource),
  1835. LLM: domain.DraftLLMContext{
  1836. BusinessType: businessType,
  1837. WebsiteURL: strings.TrimSpace(form.WebsiteURL),
  1838. WebsiteSummary: strings.TrimSpace(form.WebsiteSummary),
  1839. StyleProfile: domain.DraftStyleProfile{
  1840. LocaleStyle: strings.TrimSpace(form.LocaleStyle),
  1841. MarketStyle: strings.TrimSpace(form.MarketStyle),
  1842. AddressMode: strings.TrimSpace(form.AddressMode),
  1843. ContentTone: strings.TrimSpace(form.ContentTone),
  1844. PromptInstructions: strings.TrimSpace(form.PromptInstructions),
  1845. },
  1846. Prompt: domain.DraftPromptConfig{
  1847. Blocks: clonePromptBlocks(form.PromptBlocks),
  1848. },
  1849. },
  1850. }
  1851. }
  1852. func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings {
  1853. defaultProvider := domain.DefaultLLMProvider()
  1854. settings := domain.AppSettings{
  1855. QCBaseURL: u.cfg.QCBaseURL,
  1856. QCBearerTokenEncrypted: u.cfg.QCToken,
  1857. LanguageOutputMode: "EN",
  1858. JobPollIntervalSeconds: u.cfg.PollIntervalSeconds,
  1859. JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
  1860. LLMActiveProvider: defaultProvider,
  1861. LLMActiveModel: domain.NormalizeLLMModel(defaultProvider, ""),
  1862. LLMTemperature: domain.DefaultLLMTemperature(),
  1863. LLMMaxTokens: domain.DefaultLLMMaxTokens(),
  1864. MasterPrompt: domain.SeedMasterPrompt,
  1865. PromptBlocks: domain.DefaultPromptBlocks(),
  1866. }
  1867. if u.settings == nil {
  1868. return settings
  1869. }
  1870. stored, err := u.settings.GetSettings(ctx)
  1871. if err != nil || stored == nil {
  1872. return settings
  1873. }
  1874. if strings.TrimSpace(stored.QCBaseURL) != "" {
  1875. settings.QCBaseURL = strings.TrimSpace(stored.QCBaseURL)
  1876. }
  1877. if strings.TrimSpace(stored.QCBearerTokenEncrypted) != "" {
  1878. settings.QCBearerTokenEncrypted = strings.TrimSpace(stored.QCBearerTokenEncrypted)
  1879. }
  1880. if strings.TrimSpace(stored.LanguageOutputMode) != "" {
  1881. settings.LanguageOutputMode = strings.TrimSpace(stored.LanguageOutputMode)
  1882. }
  1883. if stored.JobPollIntervalSeconds > 0 {
  1884. settings.JobPollIntervalSeconds = stored.JobPollIntervalSeconds
  1885. }
  1886. if stored.JobPollTimeoutSeconds > 0 {
  1887. settings.JobPollTimeoutSeconds = stored.JobPollTimeoutSeconds
  1888. }
  1889. settings.LLMActiveProvider = domain.NormalizeLLMProvider(stored.LLMActiveProvider)
  1890. settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, stored.LLMActiveModel)
  1891. settings.LLMBaseURL = strings.TrimSpace(stored.LLMBaseURL)
  1892. settings.LLMTemperature = domain.NormalizeLLMTemperature(stored.LLMTemperature)
  1893. settings.LLMMaxTokens = domain.NormalizeLLMMaxTokens(stored.LLMMaxTokens)
  1894. settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(stored.OpenAIAPIKeyEncrypted)
  1895. settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(stored.AnthropicAPIKeyEncrypted)
  1896. settings.GoogleAPIKeyEncrypted = strings.TrimSpace(stored.GoogleAPIKeyEncrypted)
  1897. settings.XAIAPIKeyEncrypted = strings.TrimSpace(stored.XAIAPIKeyEncrypted)
  1898. settings.OllamaAPIKeyEncrypted = strings.TrimSpace(stored.OllamaAPIKeyEncrypted)
  1899. settings.GooglePlacesAPIKeyEncrypted = strings.TrimSpace(stored.GooglePlacesAPIKeyEncrypted)
  1900. settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt)
  1901. settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks)
  1902. return settings
  1903. }
  1904. func parsePromptBlocksFromRequest(r *http.Request) []domain.PromptBlockConfig {
  1905. count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("prompt_block_count")))
  1906. if count <= 0 {
  1907. return domain.DefaultPromptBlocks()
  1908. }
  1909. out := make([]domain.PromptBlockConfig, 0, count)
  1910. for i := 0; i < count; i++ {
  1911. id := strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_id_%d", i)))
  1912. if id == "" {
  1913. continue
  1914. }
  1915. out = append(out, domain.PromptBlockConfig{
  1916. ID: id,
  1917. Label: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_label_%d", i))),
  1918. Instruction: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_instruction_%d", i))),
  1919. Enabled: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_enabled_%d", i))) == "on",
  1920. })
  1921. }
  1922. return domain.NormalizePromptBlocks(out)
  1923. }
  1924. func clonePromptBlocks(blocks []domain.PromptBlockConfig) []domain.PromptBlockConfig {
  1925. if len(blocks) == 0 {
  1926. return nil
  1927. }
  1928. out := make([]domain.PromptBlockConfig, len(blocks))
  1929. copy(out, blocks)
  1930. return out
  1931. }
  1932. func mergePromptBlocks(current []domain.PromptBlockConfig, defaults []domain.PromptBlockConfig) []domain.PromptBlockConfig {
  1933. merged := make([]domain.PromptBlockConfig, 0, len(defaults))
  1934. merged = append(merged, clonePromptBlocks(defaults)...)
  1935. overrides := make(map[string]domain.PromptBlockConfig, len(current))
  1936. for _, block := range current {
  1937. id := strings.TrimSpace(block.ID)
  1938. if id == "" {
  1939. continue
  1940. }
  1941. overrides[id] = block
  1942. }
  1943. for i := range merged {
  1944. if override, ok := overrides[merged[i].ID]; ok {
  1945. if strings.TrimSpace(override.Label) != "" {
  1946. merged[i].Label = strings.TrimSpace(override.Label)
  1947. }
  1948. if strings.TrimSpace(override.Instruction) != "" {
  1949. merged[i].Instruction = strings.TrimSpace(override.Instruction)
  1950. }
  1951. merged[i].Enabled = override.Enabled
  1952. delete(overrides, merged[i].ID)
  1953. }
  1954. }
  1955. for _, override := range overrides {
  1956. merged = append(merged, override)
  1957. }
  1958. return domain.NormalizePromptBlocks(merged)
  1959. }
  1960. func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form buildFormInput) []domain.PromptBlockConfig {
  1961. out := clonePromptBlocks(blocks)
  1962. if len(out) == 0 {
  1963. return out
  1964. }
  1965. for i := range out {
  1966. switch out[i].ID {
  1967. case "business_type":
  1968. if strings.TrimSpace(form.BusinessType) != "" {
  1969. out[i].Enabled = true
  1970. }
  1971. case "website_summary":
  1972. if strings.TrimSpace(form.WebsiteSummary) != "" {
  1973. out[i].Enabled = true
  1974. }
  1975. case "address_mode":
  1976. if strings.TrimSpace(form.AddressMode) != "" {
  1977. out[i].Enabled = true
  1978. }
  1979. case "content_tone":
  1980. if strings.TrimSpace(form.ContentTone) != "" {
  1981. out[i].Enabled = true
  1982. }
  1983. case "free_instructions":
  1984. if strings.TrimSpace(form.PromptInstructions) != "" {
  1985. out[i].Enabled = true
  1986. }
  1987. }
  1988. }
  1989. return out
  1990. }
  1991. func semanticSlotPreview(mappingResult mapping.SemanticSlotMapping) []semanticSlotPreviewView {
  1992. if len(mappingResult.BySlot) == 0 {
  1993. return nil
  1994. }
  1995. slotKeys := make([]string, 0, len(mappingResult.BySlot))
  1996. for slot := range mappingResult.BySlot {
  1997. slotKeys = append(slotKeys, slot)
  1998. }
  1999. sort.Strings(slotKeys)
  2000. out := make([]semanticSlotPreviewView, 0, len(slotKeys))
  2001. for _, slot := range slotKeys {
  2002. targets := mappingResult.BySlot[slot]
  2003. examples := make([]string, 0, 2)
  2004. for i := 0; i < len(targets) && i < 2; i++ {
  2005. examples = append(examples, targets[i].FieldPath)
  2006. }
  2007. out = append(out, semanticSlotPreviewView{
  2008. Slot: slot,
  2009. Count: len(targets),
  2010. Examples: strings.Join(examples, ", "),
  2011. })
  2012. }
  2013. return out
  2014. }
  2015. func getString(v any) string {
  2016. s, _ := v.(string)
  2017. return strings.TrimSpace(s)
  2018. }
  2019. func defaultDraftStatus(status string) string {
  2020. switch strings.ToLower(strings.TrimSpace(status)) {
  2021. case "reviewed", "submitted":
  2022. return strings.ToLower(strings.TrimSpace(status))
  2023. default:
  2024. return "draft"
  2025. }
  2026. }
  2027. func websiteSectionOptions() []websiteSectionOptionView {
  2028. values := domain.WebsiteSectionOptions()
  2029. out := make([]websiteSectionOptionView, 0, len(values))
  2030. for _, value := range values {
  2031. out = append(out, websiteSectionOptionView{
  2032. Value: value,
  2033. Label: domain.WebsiteSectionLabel(value),
  2034. })
  2035. }
  2036. return out
  2037. }
  2038. func semanticSlotOptions() []semanticSlotOptionView {
  2039. // "" = automatisch (rule-basiertes Fallback). Reihenfolge ist UI-relevant.
  2040. slots := []semanticSlotOptionView{
  2041. {Value: "", Label: "(automatisch)"},
  2042. {Value: "hero.title", Label: "Hero - Title"},
  2043. {Value: "intro.title", Label: "Intro - Title"},
  2044. {Value: "intro.description", Label: "Intro - Description"},
  2045. {Value: "about.description", Label: "About - Description"},
  2046. {Value: "cta.text", Label: "CTA - Text"},
  2047. {Value: "service_items[0].title", Label: "Service Item #1 - Title"},
  2048. {Value: "service_items[0].description", Label: "Service Item #1 - Description"},
  2049. {Value: "service_items[1].title", Label: "Service Item #2 - Title"},
  2050. {Value: "service_items[1].description", Label: "Service Item #2 - Description"},
  2051. {Value: "service_items[2].title", Label: "Service Item #3 - Title"},
  2052. {Value: "service_items[2].description", Label: "Service Item #3 - Description"},
  2053. {Value: "service_items[3].title", Label: "Service Item #4 - Title"},
  2054. {Value: "service_items[3].description", Label: "Service Item #4 - Description"},
  2055. {Value: "service_items[4].title", Label: "Service Item #5 - Title"},
  2056. {Value: "service_items[4].description", Label: "Service Item #5 - Description"},
  2057. {Value: "team_items[0].name", Label: "Team #1 - Name"},
  2058. {Value: "team_items[0].description", Label: "Team #1 - Description"},
  2059. {Value: "team_items[1].name", Label: "Team #2 - Name"},
  2060. {Value: "team_items[1].description", Label: "Team #2 - Description"},
  2061. {Value: "team_items[2].name", Label: "Team #3 - Name"},
  2062. {Value: "team_items[2].description", Label: "Team #3 - Description"},
  2063. {Value: "testimonial_items[0].title", Label: "Testimonial #1 - Title"},
  2064. {Value: "testimonial_items[0].name", Label: "Testimonial #1 - Name"},
  2065. {Value: "testimonial_items[0].description", Label: "Testimonial #1 - Description"},
  2066. {Value: "testimonial_items[1].title", Label: "Testimonial #2 - Title"},
  2067. {Value: "testimonial_items[1].name", Label: "Testimonial #2 - Name"},
  2068. {Value: "testimonial_items[1].description", Label: "Testimonial #2 - Description"},
  2069. {Value: "testimonial_items[2].title", Label: "Testimonial #3 - Title"},
  2070. {Value: "testimonial_items[2].name", Label: "Testimonial #3 - Name"},
  2071. {Value: "testimonial_items[2].description", Label: "Testimonial #3 - Description"},
  2072. }
  2073. return slots
  2074. }