Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

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