package controllers import ( "bytes" "encoding/json" "fmt" "html" "net/http" "regexp" "strings" "time" "os" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // AIController handles AI-assisted endpoints type AIController struct { DB *gorm.DB } // GenerateCSS creates scoped CSS for a page element func (ac *AIController) GenerateCSS(c *gin.Context) { var req aiCSSRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } baseURL := getOpenRouterBaseURL() apiKey := getOpenRouterAPIKey() if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"}) return } model := getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel := getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } rootSelector := strings.TrimSpace(req.RootSelector) if rootSelector == "" { en := strings.TrimSpace(req.ElementName) if en == "" { en = "element" } rootSelector = fmt.Sprintf("[data-element=\"%s\"]", en) } themeJSON, _ := json.Marshal(req.Theme) stylesJSON, _ := json.Marshal(req.CurrentStyles) system := "Jsi zkušený CSS návrhář pro klubové weby. Piš čistý, přístupný a responzivní CSS. VÝSTUP POUZE JSON: {\"css\":\"...\"}. Nepoužívej reset, neovlivňuj globální prvky. CSS MUSÍ být scope-nuté POUZE pod kořenový selektor, žádný selektor mimo. Používej CSS proměnné (např. --club-primary, --club-secondary). Čeština není nutná v kódu, ale požadavky jsou v češtině." user := fmt.Sprintf("Požadavek: %s\nKořenový selektor: %s\nAktuální CSS (může být prázdné):\n---\n%s\n---\nAktuální styly (JSON): %s\nTéma (JSON): %s\nBreakpoints: %v\nPožadavky: 1) Scope pouze pod kořenový selektor. 2) Žádné !important. 3) Media queries pro mobil/tablet/desktop dle potřeby. 4) Zaměř se na vzhled prvků uvnitř bloku. 5) Nepřidávej inline styly ani globální sel. 6) Používej proměnné, zachovej kontrast a čitelnost.", strings.TrimSpace(req.Prompt), rootSelector, strings.TrimSpace(req.CurrentCSS), string(stylesJSON), string(themeJSON), req.Breakpoints) callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.3, "max_tokens": 1200, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e) } var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return } if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}); return } c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return } } sanitized := sanitizeAIResponse(content) var out aiCSSResponse if err := json.Unmarshal([]byte(sanitized), &out); err != nil { re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) if m := re.FindString(sanitized); m != "" { _ = json.Unmarshal([]byte(m), &out) } } if strings.TrimSpace(out.CSS) == "" { out.CSS = fmt.Sprintf("%s { }", rootSelector) } c.JSON(http.StatusOK, out) } // GenerateAboutPage creates about page content using the OpenRouter API func (ac *AIController) GenerateAboutPage(c *gin.Context) { var req aiAboutRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } clubName := strings.TrimSpace(req.ClubName) if clubName == "" { clubName = "Fotbalový klub" } style := strings.TrimSpace(req.Style) if style == "" { style = "default" } audience := strings.TrimSpace(req.Audience) if audience == "" { audience = "fanoušci klubu" } system := "Jsi zkušený editor klubových webů. Pomůžeš napsat stránku 'O klubu'. DŮLEŽITÉ: Odpovídej v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy nebo negramatické tvary. Odpovídej česky, srozumitelně a profesionálně." user := fmt.Sprintf("Poznámky k vytvoření stránky O klubu:\n---\n%s\n---\nNázev klubu: %s\nPreferovaný styl: %s\nCílové publikum: %s\n\nPovinné požadavky:\n1) Zachovej fakta z poznámek a rozšiř je o kontext (historie, hodnoty, úspěchy, tým, zázemí, komunita).\n2) Rozděl text do sekcí s HTML nadpisy (h2/h3) a odstavci (p). Můžeš použít seznamy (ul/li) tam, kde to dává smysl. Bez inline stylů.\n3) Napiš krátký podnadpis (subtitle) vystihující náladu klubu.\n4) Vytvoř SEO titulek (do 60 znaků) a SEO popis (do 160 znaků).\n5) Odpověz POUZE JSON: {\"title\":\"...\", \"subtitle\":\"...\", \"html\":\"...\", \"seo_title\":\"...\", \"seo_description\":\"...\"}.\n6) HTML pole musí obsahovat kompletní obsah stránky dle požadavků.\n", strings.TrimSpace(req.Prompt), clubName, style, audience) baseURL := getOpenRouterBaseURL() apiKey := getOpenRouterAPIKey() if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"}) return } model := getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel := getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.5, "max_tokens": 2200, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e) } var or struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } var out aiAboutResponse sanitized := sanitizeAIResponse(content) if err := json.Unmarshal([]byte(sanitized), &out); err != nil { re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) if m := re.FindString(sanitized); m != "" { _ = json.Unmarshal([]byte(m), &out) } } if out.HTML != "" { out.HTML = html.UnescapeString(out.HTML) } if out.SEOTitle == "" { out.SEOTitle = fmt.Sprintf("%s | %s", clubName, "Oficiální informace") } if out.SEODescription == "" { out.SEODescription = fmt.Sprintf("Přečtěte si informace o klubu %s.", clubName) } if out.Title == "" { out.Title = clubName } if out.Subtitle == "" { out.Subtitle = "Oficiální klubový profil" } if out.HTML == "" { out.HTML = fmt.Sprintf("

O klubu %s

%s

", htmlEscape(clubName), htmlEscape(strings.TrimSpace(req.Prompt))) } c.JSON(http.StatusOK, out) } func NewAIController(db *gorm.DB) *AIController { return &AIController{DB: db} } type aiBlogRequest struct { // Short user input that describes the topic/notes for the blog in Czech Prompt string `json:"prompt" binding:"required"` // Optional extra hints Audience string `json:"audience"` MinWords int `json:"min_words"` } type aiBlogResponse struct { Title string `json:"title"` Slug string `json:"slug"` HTML string `json:"html"` } type aiAboutRequest struct { Prompt string `json:"prompt" binding:"required"` ClubName string `json:"club_name"` Style string `json:"style"` Audience string `json:"audience"` } type aiAboutResponse struct { Title string `json:"title"` Subtitle string `json:"subtitle"` HTML string `json:"html"` SEOTitle string `json:"seo_title"` SEODescription string `json:"seo_description"` } type aiCSSRequest struct { Prompt string `json:"prompt" binding:"required"` ElementName string `json:"element_name"` RootSelector string `json:"root_selector"` CurrentCSS string `json:"current_css"` CurrentStyles map[string]interface{} `json:"current_styles"` Theme map[string]string `json:"theme"` Breakpoints []int `json:"breakpoints"` } type aiCSSResponse struct { CSS string `json:"css"` } // Instagram caption generation type aiInstaMatch struct { Home string `json:"home"` Away string `json:"away"` Competition string `json:"competition"` DateTime string `json:"date_time"` Venue string `json:"venue"` Score string `json:"score"` } type aiInstagramRequest struct { Type string `json:"type"` // "article" | "event" | "generic" Title string `json:"title"` Content string `json:"content"` // plain text, HTML will be ignored ClubName string `json:"club_name"` Link string `json:"link"` Hashtags []string `json:"hashtags"` Audience string `json:"audience"` Tone string `json:"tone"` Match *aiInstaMatch `json:"match"` } type aiInstagramResponse struct { Text string `json:"text"` } // GenerateInstagram creates an Instagram caption in Czech using OpenRouter func (ac *AIController) GenerateInstagram(c *gin.Context) { var req aiInstagramRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Normalize t := strings.ToLower(strings.TrimSpace(req.Type)) if t == "" { t = "article" } club := strings.TrimSpace(req.ClubName) if club == "" { club = "Náš klub" } audience := strings.TrimSpace(req.Audience) if audience == "" { audience = "fanoušci klubu" } tone := strings.TrimSpace(req.Tone) if tone == "" { tone = "informativní, přátelský" } // Build system and user messages system := "Jsi zkušený český social media copywriter pro fotbalový klub. Píšeš poutavé, ale profesionální popisky na Instagram v gramaticky správné češtině (bez neologismů). Buď konkrétní, z textu vyber to nejdůležitější, vyhni se klišé. Výsledek vrať POUZE JSON: {\"text\": \"...\"}." // Compose contextual notes var notes []string if req.Title != "" { notes = append(notes, "Titulek: "+req.Title) } if strings.TrimSpace(req.Content) != "" { notes = append(notes, "Obsah (zkrácený): "+strings.TrimSpace(req.Content)) } if req.Match != nil { m := req.Match line := []string{} if m.Home != "" || m.Away != "" { line = append(line, fmt.Sprintf("%s vs %s", strings.TrimSpace(m.Home), strings.TrimSpace(m.Away))) } if strings.TrimSpace(m.Score) != "" { line = append(line, "Výsledek: "+strings.TrimSpace(m.Score)) } if strings.TrimSpace(m.Competition) != "" { line = append(line, strings.TrimSpace(m.Competition)) } if strings.TrimSpace(m.DateTime) != "" { line = append(line, strings.TrimSpace(m.DateTime)) } if strings.TrimSpace(m.Venue) != "" { line = append(line, "Místo: "+strings.TrimSpace(m.Venue)) } if len(line) > 0 { notes = append(notes, "Zápas: "+strings.Join(line, " • ")) } } if strings.TrimSpace(req.Link) != "" { notes = append(notes, "Krátký odkaz: "+strings.TrimSpace(req.Link)) } if len(req.Hashtags) > 0 { notes = append(notes, "Preferované hashtagy: "+strings.Join(req.Hashtags, ", ")) } // Hard requirements requirements := []string{ "Délka 80–140 slov, rozdělit do 2–3 krátkých odstavců.", "Použij maximálně 6 emotikonů (žádné dlouhé řetězy).", "Na konec vlož oddělovač a řádek '🔗 ' následovaný přesně poskytnutým krátkým odkazem.", "Přidej 4–6 relevantních českých hashtagů (včetně klubového), přirozeně na konci.", "Drž se zadaného obsahu. Bez vymýšlení neexistujících informací.", fmt.Sprintf("Tón: %s. Publikum: %s.", tone, audience), } // Build user prompt user := fmt.Sprintf("Typ: %s\nKlub: %s\n\nPoznámky:\n- %s\n\nPožadavky:\n- %s\n\nVrať POUZE JSON bez formátování.", t, club, strings.Join(notes, "\n- "), strings.Join(requirements, "\n- ")) baseURL := getOpenRouterBaseURL() apiKey := getOpenRouterAPIKey() if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"}) return } model := getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel := getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.5, "max_tokens": 800, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e) } var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return } if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}); return } c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return } } sanitized := sanitizeAIResponse(content) var out aiInstagramResponse if err := json.Unmarshal([]byte(sanitized), &out); err != nil { re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) if m := re.FindString(sanitized); m != "" { _ = json.Unmarshal([]byte(m), &out) } } if strings.TrimSpace(out.Text) == "" { // minimal fallback txt := req.Title if txt == "" { txt = "Novinky z klubu" } out.Text = fmt.Sprintf("%s\n\n🔗 %s", txt, strings.TrimSpace(req.Link)) } c.JSON(http.StatusOK, out) } // GenerateBlog creates a blog article using the OpenRouter API (with Mistral models) func (ac *AIController) GenerateBlog(c *gin.Context) { var req aiBlogRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.MinWords <= 0 { req.MinWords = 450 } // Build instruction in Czech - emphasizing user text as primary source, but allow expansion if needed system := "Jsi asistent pro tvorbu článků. Tvým HLAVNÍM úkolem je: PŘEVZÍT TEXT OD UŽIVATELE a rozvinout ho do čitelného článku. Vždy vycházej z textu uživatele - zachovej VŠECHNY jeho informace, fakta a události. Pokud je text krátký, přidej kontext, rozvinutí a souvislosti, ale vždy kolem témat a informací z textu uživatele. Rozděl text do logických odstavců, přidej vhodné HTML značky (nadpisy h2/h3, odstavce p, seznamy ul/ol). DŮLEŽITÉ: Píšeš v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy nebo negramatické tvary (např. místo 'nevděkovaný' použij 'nevděčný'). Píšeš srozumitelně a čtivě pro fotbalové fanoušky. HTML výstup bez inline stylů." user := fmt.Sprintf("Text od uživatele (VŽDY z něj vycházej, zachovej všechny jeho informace):\n---\n%s\n---\nPublikum: %s\nCílová délka: %d slov.\n\nPOVINNÉ POŽADAVKY:\n1) ZACHOVEJ všechny informace, jména, události a fakta z textu uživatele. To je ZÁKLAD článku.\n2) Pokud je text krátký (pod %d slov), ROZVIŇ ho - přidej kontext, atmosféru, detaily kolem událostí z textu uživatele. Buď čtivý a zajímavý.\n3) Pokud je text dostatečně dlouhý, pouze ho strukturuj do HTML s nadpisy a odstavci.\n4) Vygeneruj výstižný titulek vycházející z obsahu textu uživatele.\n5) Vytvoř URL slug (3-5 slov, max. 40 znaků, lowercase, bez diakritiky, jen písmena/číslice a pomlčky).\n6) Odpověz POUZE JSON: {\"title\": \"...\", \"slug\": \"...\", \"html\": \"...\"}\n7) HTML obsah = text uživatele + rozvinutí (pokud nutné) strukturovaný do HTML tagů (h2, p, ul, ol). BEZ inline stylů.\n\nPAMATUJ: Text uživatele = základ. Pokud je krátký, rozviň ho čtivě a zajímavě pro %s.\n", strings.TrimSpace(req.Prompt), strings.TrimSpace(req.Audience), req.MinWords, req.MinWords, strings.TrimSpace(req.Audience)) // Prepare OpenRouter request baseURL := getOpenRouterBaseURL() apiKey := getOpenRouterAPIKey() if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"}) return } // Primary and fallback models model := getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel := getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } // Helper to call OpenRouter with a given model and return content callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.5, "max_tokens": 2000, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") // Optional but recommended headers for OpenRouter if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e) } // OpenAI-compatible response var or struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } // Try primary, then fallback content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { // Attempt fallback model if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { // Provide the primary error if available if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } // Sanitize and parse JSON returned by the model var out aiBlogResponse // Clean up the response: remove markdown code blocks, backticks, etc. sanitized := sanitizeAIResponse(content) // Try to parse the sanitized content if err := json.Unmarshal([]byte(sanitized), &out); err != nil { // Best-effort: try to find JSON block re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) if m := re.FindString(sanitized); m != "" { _ = json.Unmarshal([]byte(m), &out) } } // Decode HTML entities in the html field if out.HTML != "" { out.HTML = html.UnescapeString(out.HTML) } // Fallbacks if the model did not provide title/slug if out.Title == "" { out.Title = deriveTitle(req.Prompt) } // Validate slug: short, independent from title. If not valid, derive from prompt. if !isValidShortSlug(out.Slug) || out.Slug == slugify(out.Title) { out.Slug = shortSlugFromPrompt(req.Prompt) } if out.HTML == "" { // Wrap raw content as paragraph fallback out.HTML = "

" + htmlEscape(out.Title) + "

" + htmlEscape(content) + "

" } c.JSON(http.StatusOK, out) } // Helpers for OpenRouter config func getOpenRouterAPIKey() string { if v := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(getenv("OPENROUTER_API_KEY")), "\"")); v != "" { return v } return "" } func getOpenRouterBaseURL() string { if v := strings.TrimSpace(getenv("OPENROUTER_BASE_URL")); v != "" { return v } return "https://openrouter.ai/api/v1" } func getOpenRouterModel() string { if v := strings.TrimSpace(getenv("OPENROUTER_MODEL")); v != "" { return v } return "" } func getOpenRouterFallbackModel() string { if v := strings.TrimSpace(getenv("OPENROUTER_FALLBACK_MODEL")); v != "" { return v } return "" } // Small utility wrappers to avoid importing os directly multiple times func getenv(k string) string { return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(os.Getenv(k)), "\r", ""), "\n", "")) } // deriveTitle returns a readable title from user prompt func deriveTitle(s string) string { s = strings.TrimSpace(s) if s == "" { return "Novinky z klubu" } // Capitalize first letter, keep it concise if len(s) > 120 { s = s[:120] } return strings.ToUpper(string([]rune(s)[:1])) + s[1:] } // slugify creates a URL-friendly slug without diacritics func slugify(s string) string { s = strings.ToLower(strings.TrimSpace(s)) // Replace diacritics (basic map); for full support, consider x/text/unicode/norm and transform replacer := strings.NewReplacer( "á", "a", "č", "c", "ď", "d", "é", "e", "ě", "e", "í", "i", "ň", "n", "ó", "o", "ř", "r", "š", "s", "ť", "t", "ú", "u", "ů", "u", "ý", "y", "ž", "z", ) s = replacer.Replace(s) // Replace any non alnum with hyphen re := regexp.MustCompile("[^a-z0-9]+") s = re.ReplaceAllString(s, "-") s = strings.Trim(s, "-") if s == "" { return "clanek" } return s } // isValidShortSlug checks basic constraints: non-empty, <= 40 chars, 3-5 words (by hyphens), allowed charset func isValidShortSlug(s string) bool { s = strings.TrimSpace(s) if s == "" { return false } if len(s) > 40 { return false } parts := strings.Split(s, "-") // filter empty parts w := 0 for _, p := range parts { if p != "" { w++ } } if w < 3 || w > 5 { return false } // allowed chars: a-z0-9- re := regexp.MustCompile(`^[a-z0-9-]+$`) return re.MatchString(s) } // shortSlugFromPrompt creates a compact, independent slug from the prompt text func shortSlugFromPrompt(prompt string) string { p := strings.ToLower(strings.TrimSpace(prompt)) if p == "" { return "clanek" } // basic diacritics removal via slugify, then split to words p = slugify(p) parts := strings.Split(p, "-") // simple Czech stopwords list (subset) stop := map[string]struct{}{"a":{},"i":{},"v":{},"ve":{},"z":{},"za":{},"od":{},"do":{},"u":{},"o":{},"s":{},"se":{},"na":{},"po":{},"pod":{},"nad":{},"proti":{},"pri":{},"bez":{},"k":{},"ke":{},"ten":{},"ta":{},"to":{},"ty":{},"tento":{},"tato":{},"toto":{},"jak":{},"jako":{},"ze":{}} var kept []string for _, w := range parts { if w == "" { continue } if _, ok := stop[w]; ok { continue } kept = append(kept, w) if len(kept) >= 5 { break } } if len(kept) == 0 { kept = parts } // prefer 3-5 words, trim to 4 if too many if len(kept) > 5 { kept = kept[:5] } if len(kept) >= 4 { kept = kept[:4] } s := strings.Join(kept, "-") if len(s) > 40 { s = s[:40] } s = strings.Trim(s, "-") if !isValidShortSlug(s) { // final fallback s = slugify(deriveTitle(prompt)) if len(s) > 40 { s = s[:40] } s = strings.Trim(s, "-") } return s } func htmlEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "\"", """) s = strings.ReplaceAll(s, "'", "'") return s } // sanitizeAIResponse cleans up AI response to extract valid JSON // Handles markdown code blocks, extra backticks, and other formatting issues func sanitizeAIResponse(content string) string { // Trim whitespace content = strings.TrimSpace(content) // Remove markdown code block markers (```json, ``json, `, etc.) // Handle various formats: ```json\n{...}\n```, ``json{...}``, `{...}` content = regexp.MustCompile(`^\s*`+"`"+`{1,3}\s*json\s*`).ReplaceAllString(content, "") content = regexp.MustCompile(`\s*`+"`"+`{1,3}\s*$`).ReplaceAllString(content, "") // Remove any remaining backticks at start/end content = strings.Trim(content, "`") content = strings.TrimSpace(content) return content }