Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

1954 lignes
65KB

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