Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

2155 řádky
71KB

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