| @@ -10,6 +10,7 @@ Die App kann heute: | |||||
| - Drafts anlegen, aktualisieren und im Status `draft` -> `reviewed` -> `submitted` fuehren. | - Drafts anlegen, aktualisieren und im Status `draft` -> `reviewed` -> `submitted` fuehren. | ||||
| - Externen Draft-Intake ueber `POST /api/drafts/intake` verarbeiten (Stammdaten + optional Website-/Stilkontext, kein Direkt-Build). | - Externen Draft-Intake ueber `POST /api/drafts/intake` verarbeiten (Stammdaten + optional Website-/Stilkontext, kein Direkt-Build). | ||||
| - Globalen Master-Prompt in Settings pflegen sowie Prompt-Bloecke fuer den spaeteren LLM-Flow als Standard konfigurieren. | - Globalen Master-Prompt in Settings pflegen sowie Prompt-Bloecke fuer den spaeteren LLM-Flow als Standard konfigurieren. | ||||
| - Im Settings-/Config-Bereich die LLM-Basiskonfiguration pflegen: aktiver Provider, aktives Modell, Base URL fuer Ollama/kompatible Endpoints sowie getrennte API-Key-Speicher je Provider (OpenAI, Anthropic, Google, xAI, Ollama). | |||||
| - Im Draft-/Build-UI den User-Flow auf Stammdaten, Intake-/Website-Kontext, Stil-Auswahl und Template-Felder fokussieren; Prompt-Interna liegen in Settings. | - Im Draft-/Build-UI den User-Flow auf Stammdaten, Intake-/Website-Kontext, Stil-Auswahl und Template-Felder fokussieren; Prompt-Interna liegen in Settings. | ||||
| - Interne semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) auf Template-Felder abbilden als Vorbereitung fuer spaeteren LLM-Autofill. | - Interne semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) auf Template-Felder abbilden als Vorbereitung fuer spaeteren LLM-Autofill. | ||||
| - Repeated-Bereiche in semantischen Slots werden block-/rollenbasiert getrennt (z. B. Services/Team/Testimonials pro Item statt Sammel-Slot). | - Repeated-Bereiche in semantischen Slots werden block-/rollenbasiert getrennt (z. B. Services/Team/Testimonials pro Item statt Sammel-Slot). | ||||
| @@ -21,6 +22,7 @@ Die App kann heute: | |||||
| Wichtig: | Wichtig: | ||||
| - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. | - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. | ||||
| - LLM-Autofill bleibt Assistenz im Review-Flow: Vorschlaege werden separat gespeichert und manuell angewendet; bei LLM-Ausfall greift deterministischer Rule-based Fallback. | - LLM-Autofill bleibt Assistenz im Review-Flow: Vorschlaege werden separat gespeichert und manuell angewendet; bei LLM-Ausfall greift deterministischer Rule-based Fallback. | ||||
| - Die neue Provider-/Modell-Konfiguration ist Phase-A-Grundlage fuer spaeteres Routing; der bestehende LLM-Suggestions-Runtimepfad bleibt in diesem Schritt unveraendert. | |||||
| ## Lokaler Start | ## Lokaler Start | ||||
| @@ -36,7 +38,7 @@ Wichtig: | |||||
| ## Persistenz | ## Persistenz | ||||
| Default ist SQLite. | Default ist SQLite. | ||||
| Gespeichert werden Settings, Templates, Manifeste/Felder, Drafts und Site-Builds. | |||||
| Gespeichert werden Settings (inkl. Prompt-Konfig und LLM-Provider-/Modell-/Key-Grundlagen), Templates, Manifeste/Felder, Drafts und Site-Builds. | |||||
| ## Draft-/Review-Flow | ## Draft-/Review-Flow | ||||
| @@ -42,6 +42,7 @@ Aktueller Stand: | |||||
| - Semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) werden intern auf konkrete Template-Felder gemappt als Vorbereitung fuer spaeteren LLM-Autofill. | - Semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) werden intern auf konkrete Template-Felder gemappt als Vorbereitung fuer spaeteren LLM-Autofill. | ||||
| - Repeated-Sektionen (u. a. Services/Team/Testimonials) werden in der Slot-Vorschau block- und rollentypisch pro Item getrennt statt in Sammel-Slots zusammenzufallen. | - Repeated-Sektionen (u. a. Services/Team/Testimonials) werden in der Slot-Vorschau block- und rollentypisch pro Item getrennt statt in Sammel-Slots zusammenzufallen. | ||||
| - LLM-first Suggestion-State fuer Draft-/Build-UI ist vorhanden: Vorschlaege werden separat von Feldwerten gespeichert und per Generate/Regenerate/Apply (global und per Feld) explizit gesteuert; Rule-based bleibt als Fallback/Testpfad aktiv. | - LLM-first Suggestion-State fuer Draft-/Build-UI ist vorhanden: Vorschlaege werden separat von Feldwerten gespeichert und per Generate/Regenerate/Apply (global und per Feld) explizit gesteuert; Rule-based bleibt als Fallback/Testpfad aktiv. | ||||
| - Settings-Grundlage fuer spaetere Providerwahl ist vorhanden: aktiver LLM-Provider, aktives Modell, Base URL fuer Ollama/kompatible Endpoints sowie getrennte API-Key-Felder je Provider (OpenAI, Anthropic, Google, xAI, Ollama) sind persistent in `app_settings`. | |||||
| - Technische Felddetails (z. B. Feldpfade/Slots/Suggestion-Metadaten) sind im UI per Debug-Toggle optional einblendbar. | - Technische Felddetails (z. B. Feldpfade/Slots/Suggestion-Metadaten) sind im UI per Debug-Toggle optional einblendbar. | ||||
| - Build-Start erfordert bereits einen Template-Manifest-Status `reviewed`/`validated`. | - Build-Start erfordert bereits einen Template-Manifest-Status `reviewed`/`validated`. | ||||
| - Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut. | - Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut. | ||||
| @@ -108,6 +109,7 @@ Statusmarker: | |||||
| - [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontext wird in den LLM-Pfad uebergeben; Qualitaets-/Governance-Feinschliff offen). | - [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontext wird in den LLM-Pfad uebergeben; Qualitaets-/Governance-Feinschliff offen). | ||||
| - [-] Prompt-/Systemsteuerung (Master-Prompt + Prompt-Bloecke) in Settings in den LLM-Suggestionspfad eingebunden; Build-Flow ohne prominente Prompt-Interna. | - [-] Prompt-/Systemsteuerung (Master-Prompt + Prompt-Bloecke) in Settings in den LLM-Suggestionspfad eingebunden; Build-Flow ohne prominente Prompt-Interna. | ||||
| - [x] Semantische Slot-Mappings zwischen Template-Feldern und Zielrollen als Bruecke fuer LLM-Autofill aktiv genutzt (inkl. verbesserter Trennung in Repeated-Bereichen). | - [x] Semantische Slot-Mappings zwischen Template-Feldern und Zielrollen als Bruecke fuer LLM-Autofill aktiv genutzt (inkl. verbesserter Trennung in Repeated-Bereichen). | ||||
| - [-] Phase A Provider-/Modell-Settings-Fundament in Settings/UI/Persistenz umgesetzt (inkl. provider-spezifischer Key-Speicherung); produktive Runtime-Umschaltung pro Provider/Modell folgt in spaeteren Phasen. | |||||
| ### F) Security und Betriebsreife | ### F) Security und Betriebsreife | ||||
| - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | ||||
| @@ -84,10 +84,20 @@ func New(cfg config.Config) (*App, error) { | |||||
| LanguageOutputMode: "EN", | LanguageOutputMode: "EN", | ||||
| JobPollIntervalSeconds: cfg.PollIntervalSeconds, | JobPollIntervalSeconds: cfg.PollIntervalSeconds, | ||||
| JobPollTimeoutSeconds: cfg.PollTimeoutSeconds, | JobPollTimeoutSeconds: cfg.PollTimeoutSeconds, | ||||
| LLMActiveProvider: domain.DefaultLLMProvider(), | |||||
| LLMActiveModel: domain.NormalizeLLMModel(domain.DefaultLLMProvider(), ""), | |||||
| MasterPrompt: domain.SeedMasterPrompt, | MasterPrompt: domain.SeedMasterPrompt, | ||||
| PromptBlocks: domain.DefaultPromptBlocks(), | PromptBlocks: domain.DefaultPromptBlocks(), | ||||
| } | } | ||||
| if existing, err := settingsStore.GetSettings(context.Background()); err == nil && existing != nil { | if existing, err := settingsStore.GetSettings(context.Background()); err == nil && existing != nil { | ||||
| baseSettings.LLMActiveProvider = existing.LLMActiveProvider | |||||
| baseSettings.LLMActiveModel = existing.LLMActiveModel | |||||
| baseSettings.LLMBaseURL = existing.LLMBaseURL | |||||
| baseSettings.OpenAIAPIKeyEncrypted = existing.OpenAIAPIKeyEncrypted | |||||
| baseSettings.AnthropicAPIKeyEncrypted = existing.AnthropicAPIKeyEncrypted | |||||
| baseSettings.GoogleAPIKeyEncrypted = existing.GoogleAPIKeyEncrypted | |||||
| baseSettings.XAIAPIKeyEncrypted = existing.XAIAPIKeyEncrypted | |||||
| baseSettings.OllamaAPIKeyEncrypted = existing.OllamaAPIKeyEncrypted | |||||
| baseSettings.MasterPrompt = existing.MasterPrompt | baseSettings.MasterPrompt = existing.MasterPrompt | ||||
| baseSettings.PromptBlocks = existing.PromptBlocks | baseSettings.PromptBlocks = existing.PromptBlocks | ||||
| } | } | ||||
| @@ -102,6 +112,7 @@ func New(cfg config.Config) (*App, error) { | |||||
| server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { | server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { | ||||
| r.Get("/", ui.Home) | r.Get("/", ui.Home) | ||||
| r.Get("/settings", ui.Settings) | r.Get("/settings", ui.Settings) | ||||
| r.Post("/settings/llm", ui.SaveLLMSettings) | |||||
| r.Post("/settings/prompt", ui.SavePromptSettings) | r.Post("/settings/prompt", ui.SavePromptSettings) | ||||
| r.Get("/templates", ui.Templates) | r.Get("/templates", ui.Templates) | ||||
| r.Post("/templates/sync", ui.SyncTemplates) | r.Post("/templates/sync", ui.SyncTemplates) | ||||
| @@ -0,0 +1,108 @@ | |||||
| package domain | |||||
| import "strings" | |||||
| const ( | |||||
| LLMProviderOpenAI = "openai" | |||||
| LLMProviderAnthropic = "anthropic" | |||||
| LLMProviderGoogle = "google" | |||||
| LLMProviderXAI = "xai" | |||||
| LLMProviderOllama = "ollama" | |||||
| ) | |||||
| type LLMModelOption struct { | |||||
| Value string | |||||
| Label string | |||||
| } | |||||
| type LLMProviderOption struct { | |||||
| Value string | |||||
| Label string | |||||
| Models []LLMModelOption | |||||
| } | |||||
| func DefaultLLMProvider() string { | |||||
| return LLMProviderOpenAI | |||||
| } | |||||
| func LLMProviderOptions() []LLMProviderOption { | |||||
| return []LLMProviderOption{ | |||||
| { | |||||
| Value: LLMProviderOpenAI, | |||||
| Label: "OpenAI", | |||||
| Models: []LLMModelOption{ | |||||
| {Value: "gpt-5.2", Label: "gpt-5.2"}, | |||||
| {Value: "gpt-5.4", Label: "gpt-5.4"}, | |||||
| }, | |||||
| }, | |||||
| { | |||||
| Value: LLMProviderAnthropic, | |||||
| Label: "Anthropic", | |||||
| Models: []LLMModelOption{ | |||||
| {Value: "claude-sonnet-4-5", Label: "claude-sonnet-4-5"}, | |||||
| {Value: "claude-opus-4-1", Label: "claude-opus-4-1"}, | |||||
| }, | |||||
| }, | |||||
| { | |||||
| Value: LLMProviderGoogle, | |||||
| Label: "Google", | |||||
| Models: []LLMModelOption{ | |||||
| {Value: "gemini-2.5-pro", Label: "gemini-2.5-pro"}, | |||||
| {Value: "gemini-2.5-flash", Label: "gemini-2.5-flash"}, | |||||
| }, | |||||
| }, | |||||
| { | |||||
| Value: LLMProviderXAI, | |||||
| Label: "xAI", | |||||
| Models: []LLMModelOption{ | |||||
| {Value: "grok-4", Label: "grok-4"}, | |||||
| {Value: "grok-3-mini", Label: "grok-3-mini"}, | |||||
| }, | |||||
| }, | |||||
| { | |||||
| Value: LLMProviderOllama, | |||||
| Label: "Ollama", | |||||
| Models: []LLMModelOption{ | |||||
| {Value: "llama3.2", Label: "llama3.2"}, | |||||
| {Value: "qwen2.5", Label: "qwen2.5"}, | |||||
| {Value: "mistral", Label: "mistral"}, | |||||
| }, | |||||
| }, | |||||
| } | |||||
| } | |||||
| func LLMModelsByProvider(provider string) []LLMModelOption { | |||||
| normalized := NormalizeLLMProvider(provider) | |||||
| for _, option := range LLMProviderOptions() { | |||||
| if option.Value == normalized { | |||||
| out := make([]LLMModelOption, len(option.Models)) | |||||
| copy(out, option.Models) | |||||
| return out | |||||
| } | |||||
| } | |||||
| return nil | |||||
| } | |||||
| func NormalizeLLMProvider(provider string) string { | |||||
| value := strings.ToLower(strings.TrimSpace(provider)) | |||||
| for _, option := range LLMProviderOptions() { | |||||
| if option.Value == value { | |||||
| return value | |||||
| } | |||||
| } | |||||
| return DefaultLLMProvider() | |||||
| } | |||||
| func NormalizeLLMModel(provider, model string) string { | |||||
| models := LLMModelsByProvider(provider) | |||||
| if len(models) == 0 { | |||||
| return "" | |||||
| } | |||||
| value := strings.TrimSpace(model) | |||||
| for _, option := range models { | |||||
| if option.Value == value { | |||||
| return value | |||||
| } | |||||
| } | |||||
| return models[0].Value | |||||
| } | |||||
| @@ -146,11 +146,19 @@ type DraftContext struct { | |||||
| } | } | ||||
| type AppSettings struct { | type AppSettings struct { | ||||
| QCBaseURL string `json:"qcBaseUrl"` | |||||
| QCBearerTokenEncrypted string `json:"qcBearerTokenEncrypted"` | |||||
| LanguageOutputMode string `json:"languageOutputMode"` | |||||
| JobPollIntervalSeconds int `json:"jobPollIntervalSeconds"` | |||||
| JobPollTimeoutSeconds int `json:"jobPollTimeoutSeconds"` | |||||
| MasterPrompt string `json:"masterPrompt,omitempty"` | |||||
| PromptBlocks []PromptBlockConfig `json:"promptBlocks,omitempty"` | |||||
| QCBaseURL string `json:"qcBaseUrl"` | |||||
| QCBearerTokenEncrypted string `json:"qcBearerTokenEncrypted"` | |||||
| LanguageOutputMode string `json:"languageOutputMode"` | |||||
| JobPollIntervalSeconds int `json:"jobPollIntervalSeconds"` | |||||
| JobPollTimeoutSeconds int `json:"jobPollTimeoutSeconds"` | |||||
| LLMActiveProvider string `json:"llmActiveProvider,omitempty"` | |||||
| LLMActiveModel string `json:"llmActiveModel,omitempty"` | |||||
| LLMBaseURL string `json:"llmBaseUrl,omitempty"` | |||||
| OpenAIAPIKeyEncrypted string `json:"openAiApiKeyEncrypted,omitempty"` | |||||
| AnthropicAPIKeyEncrypted string `json:"anthropicApiKeyEncrypted,omitempty"` | |||||
| GoogleAPIKeyEncrypted string `json:"googleApiKeyEncrypted,omitempty"` | |||||
| XAIAPIKeyEncrypted string `json:"xaiApiKeyEncrypted,omitempty"` | |||||
| OllamaAPIKeyEncrypted string `json:"ollamaApiKeyEncrypted,omitempty"` | |||||
| MasterPrompt string `json:"masterPrompt,omitempty"` | |||||
| PromptBlocks []PromptBlockConfig `json:"promptBlocks,omitempty"` | |||||
| } | } | ||||
| @@ -54,14 +54,24 @@ type homePageData struct { | |||||
| type settingsPageData struct { | type settingsPageData struct { | ||||
| pageData | pageData | ||||
| QCBaseURL string | |||||
| PollIntervalSeconds int | |||||
| PollTimeoutSeconds int | |||||
| PollMaxConcurrent int | |||||
| TokenConfigured bool | |||||
| LanguageOutputMode string | |||||
| MasterPrompt string | |||||
| PromptBlocks []domain.PromptBlockConfig | |||||
| QCBaseURL string | |||||
| PollIntervalSeconds int | |||||
| PollTimeoutSeconds int | |||||
| PollMaxConcurrent int | |||||
| TokenConfigured bool | |||||
| LanguageOutputMode string | |||||
| LLMProviderOptions []domain.LLMProviderOption | |||||
| LLMModelOptions []domain.LLMModelOption | |||||
| LLMActiveProvider string | |||||
| LLMActiveModel string | |||||
| LLMBaseURL string | |||||
| OpenAIKeyConfigured bool | |||||
| AnthropicKeyConfigured bool | |||||
| GoogleKeyConfigured bool | |||||
| XAIKeyConfigured bool | |||||
| OllamaKeyConfigured bool | |||||
| MasterPrompt string | |||||
| PromptBlocks []domain.PromptBlockConfig | |||||
| } | } | ||||
| type templatesPageData struct { | type templatesPageData struct { | ||||
| @@ -234,16 +244,28 @@ func (u *UI) Home(w http.ResponseWriter, r *http.Request) { | |||||
| func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { | func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { | ||||
| settings := u.loadPromptSettings(r.Context()) | settings := u.loadPromptSettings(r.Context()) | ||||
| activeProvider := domain.NormalizeLLMProvider(settings.LLMActiveProvider) | |||||
| modelOptions := domain.LLMModelsByProvider(activeProvider) | |||||
| u.render.Render(w, "settings", settingsPageData{ | u.render.Render(w, "settings", settingsPageData{ | ||||
| pageData: basePageData(r, "Settings", "/settings"), | |||||
| QCBaseURL: u.cfg.QCBaseURL, | |||||
| PollIntervalSeconds: u.cfg.PollIntervalSeconds, | |||||
| PollTimeoutSeconds: u.cfg.PollTimeoutSeconds, | |||||
| PollMaxConcurrent: u.cfg.PollMaxConcurrent, | |||||
| TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "", | |||||
| LanguageOutputMode: "EN", | |||||
| MasterPrompt: settings.MasterPrompt, | |||||
| PromptBlocks: settings.PromptBlocks, | |||||
| pageData: basePageData(r, "Settings", "/settings"), | |||||
| QCBaseURL: u.cfg.QCBaseURL, | |||||
| PollIntervalSeconds: u.cfg.PollIntervalSeconds, | |||||
| PollTimeoutSeconds: u.cfg.PollTimeoutSeconds, | |||||
| PollMaxConcurrent: u.cfg.PollMaxConcurrent, | |||||
| TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "", | |||||
| LanguageOutputMode: "EN", | |||||
| LLMProviderOptions: domain.LLMProviderOptions(), | |||||
| LLMModelOptions: modelOptions, | |||||
| LLMActiveProvider: activeProvider, | |||||
| LLMActiveModel: domain.NormalizeLLMModel(activeProvider, settings.LLMActiveModel), | |||||
| LLMBaseURL: strings.TrimSpace(settings.LLMBaseURL), | |||||
| OpenAIKeyConfigured: strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) != "", | |||||
| AnthropicKeyConfigured: strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) != "", | |||||
| GoogleKeyConfigured: strings.TrimSpace(settings.GoogleAPIKeyEncrypted) != "", | |||||
| XAIKeyConfigured: strings.TrimSpace(settings.XAIAPIKeyEncrypted) != "", | |||||
| OllamaKeyConfigured: strings.TrimSpace(settings.OllamaAPIKeyEncrypted) != "", | |||||
| MasterPrompt: settings.MasterPrompt, | |||||
| PromptBlocks: settings.PromptBlocks, | |||||
| }) | }) | ||||
| } | } | ||||
| @@ -262,6 +284,37 @@ func (u *UI) SavePromptSettings(w http.ResponseWriter, r *http.Request) { | |||||
| http.Redirect(w, r, "/settings?msg=prompt+settings+saved", http.StatusSeeOther) | http.Redirect(w, r, "/settings?msg=prompt+settings+saved", http.StatusSeeOther) | ||||
| } | } | ||||
| func (u *UI) SaveLLMSettings(w http.ResponseWriter, r *http.Request) { | |||||
| if err := r.ParseForm(); err != nil { | |||||
| http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| settings := u.loadPromptSettings(r.Context()) | |||||
| settings.LLMActiveProvider = domain.NormalizeLLMProvider(r.FormValue("llm_provider")) | |||||
| settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, r.FormValue("llm_model")) | |||||
| settings.LLMBaseURL = strings.TrimSpace(r.FormValue("llm_base_url")) | |||||
| if value := strings.TrimSpace(r.FormValue("llm_api_key_openai")); value != "" { | |||||
| settings.OpenAIAPIKeyEncrypted = value | |||||
| } | |||||
| if value := strings.TrimSpace(r.FormValue("llm_api_key_anthropic")); value != "" { | |||||
| settings.AnthropicAPIKeyEncrypted = value | |||||
| } | |||||
| if value := strings.TrimSpace(r.FormValue("llm_api_key_google")); value != "" { | |||||
| settings.GoogleAPIKeyEncrypted = value | |||||
| } | |||||
| if value := strings.TrimSpace(r.FormValue("llm_api_key_xai")); value != "" { | |||||
| settings.XAIAPIKeyEncrypted = value | |||||
| } | |||||
| if value := strings.TrimSpace(r.FormValue("llm_api_key_ollama")); value != "" { | |||||
| settings.OllamaAPIKeyEncrypted = value | |||||
| } | |||||
| if err := u.settings.UpsertSettings(r.Context(), settings); err != nil { | |||||
| http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther) | |||||
| return | |||||
| } | |||||
| http.Redirect(w, r, "/settings?msg=llm+settings+saved", http.StatusSeeOther) | |||||
| } | |||||
| func (u *UI) Templates(w http.ResponseWriter, r *http.Request) { | func (u *UI) Templates(w http.ResponseWriter, r *http.Request) { | ||||
| templates, err := u.templateSvc.ListTemplates(r.Context()) | templates, err := u.templateSvc.ListTemplates(r.Context()) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -1598,12 +1651,15 @@ func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) * | |||||
| } | } | ||||
| func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { | func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { | ||||
| defaultProvider := domain.DefaultLLMProvider() | |||||
| settings := domain.AppSettings{ | settings := domain.AppSettings{ | ||||
| QCBaseURL: u.cfg.QCBaseURL, | QCBaseURL: u.cfg.QCBaseURL, | ||||
| QCBearerTokenEncrypted: u.cfg.QCToken, | QCBearerTokenEncrypted: u.cfg.QCToken, | ||||
| LanguageOutputMode: "EN", | LanguageOutputMode: "EN", | ||||
| JobPollIntervalSeconds: u.cfg.PollIntervalSeconds, | JobPollIntervalSeconds: u.cfg.PollIntervalSeconds, | ||||
| JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds, | JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds, | ||||
| LLMActiveProvider: defaultProvider, | |||||
| LLMActiveModel: domain.NormalizeLLMModel(defaultProvider, ""), | |||||
| MasterPrompt: domain.SeedMasterPrompt, | MasterPrompt: domain.SeedMasterPrompt, | ||||
| PromptBlocks: domain.DefaultPromptBlocks(), | PromptBlocks: domain.DefaultPromptBlocks(), | ||||
| } | } | ||||
| @@ -1629,6 +1685,14 @@ func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { | |||||
| if stored.JobPollTimeoutSeconds > 0 { | if stored.JobPollTimeoutSeconds > 0 { | ||||
| settings.JobPollTimeoutSeconds = stored.JobPollTimeoutSeconds | settings.JobPollTimeoutSeconds = stored.JobPollTimeoutSeconds | ||||
| } | } | ||||
| settings.LLMActiveProvider = domain.NormalizeLLMProvider(stored.LLMActiveProvider) | |||||
| settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, stored.LLMActiveModel) | |||||
| settings.LLMBaseURL = strings.TrimSpace(stored.LLMBaseURL) | |||||
| settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(stored.OpenAIAPIKeyEncrypted) | |||||
| settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(stored.AnthropicAPIKeyEncrypted) | |||||
| settings.GoogleAPIKeyEncrypted = strings.TrimSpace(stored.GoogleAPIKeyEncrypted) | |||||
| settings.XAIAPIKeyEncrypted = strings.TrimSpace(stored.XAIAPIKeyEncrypted) | |||||
| settings.OllamaAPIKeyEncrypted = strings.TrimSpace(stored.OllamaAPIKeyEncrypted) | |||||
| settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt) | settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt) | ||||
| settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks) | settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks) | ||||
| return settings | return settings | ||||
| @@ -0,0 +1,23 @@ | |||||
| ALTER TABLE app_settings | |||||
| ADD COLUMN llm_active_provider TEXT NOT NULL DEFAULT 'openai'; | |||||
| ALTER TABLE app_settings | |||||
| ADD COLUMN llm_active_model TEXT NOT NULL DEFAULT ''; | |||||
| ALTER TABLE app_settings | |||||
| ADD COLUMN llm_base_url TEXT NOT NULL DEFAULT ''; | |||||
| ALTER TABLE app_settings | |||||
| ADD COLUMN openai_api_key_encrypted TEXT NOT NULL DEFAULT ''; | |||||
| ALTER TABLE app_settings | |||||
| ADD COLUMN anthropic_api_key_encrypted TEXT NOT NULL DEFAULT ''; | |||||
| ALTER TABLE app_settings | |||||
| ADD COLUMN google_api_key_encrypted TEXT NOT NULL DEFAULT ''; | |||||
| ALTER TABLE app_settings | |||||
| ADD COLUMN xai_api_key_encrypted TEXT NOT NULL DEFAULT ''; | |||||
| ALTER TABLE app_settings | |||||
| ADD COLUMN ollama_api_key_encrypted TEXT NOT NULL DEFAULT ''; | |||||
| @@ -410,16 +410,29 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) | |||||
| if err != nil { | if err != nil { | ||||
| return fmt.Errorf("marshal prompt blocks: %w", err) | return fmt.Errorf("marshal prompt blocks: %w", err) | ||||
| } | } | ||||
| provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider) | |||||
| model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel) | |||||
| _, err = s.db.ExecContext(ctx, ` | _, err = s.db.ExecContext(ctx, ` | ||||
| INSERT INTO app_settings ( | INSERT INTO app_settings ( | ||||
| id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, master_prompt, prompt_blocks_json, updated_at | |||||
| ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?) | |||||
| id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, | |||||
| llm_active_provider, llm_active_model, llm_base_url, | |||||
| openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted, | |||||
| master_prompt, prompt_blocks_json, updated_at | |||||
| ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |||||
| ON CONFLICT(id) DO UPDATE SET | ON CONFLICT(id) DO UPDATE SET | ||||
| qc_base_url = excluded.qc_base_url, | qc_base_url = excluded.qc_base_url, | ||||
| qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted, | qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted, | ||||
| language_output_mode = excluded.language_output_mode, | language_output_mode = excluded.language_output_mode, | ||||
| job_poll_interval_seconds = excluded.job_poll_interval_seconds, | job_poll_interval_seconds = excluded.job_poll_interval_seconds, | ||||
| job_poll_timeout_seconds = excluded.job_poll_timeout_seconds, | job_poll_timeout_seconds = excluded.job_poll_timeout_seconds, | ||||
| llm_active_provider = excluded.llm_active_provider, | |||||
| llm_active_model = excluded.llm_active_model, | |||||
| llm_base_url = excluded.llm_base_url, | |||||
| openai_api_key_encrypted = excluded.openai_api_key_encrypted, | |||||
| anthropic_api_key_encrypted = excluded.anthropic_api_key_encrypted, | |||||
| google_api_key_encrypted = excluded.google_api_key_encrypted, | |||||
| xai_api_key_encrypted = excluded.xai_api_key_encrypted, | |||||
| ollama_api_key_encrypted = excluded.ollama_api_key_encrypted, | |||||
| master_prompt = excluded.master_prompt, | master_prompt = excluded.master_prompt, | ||||
| prompt_blocks_json = excluded.prompt_blocks_json, | prompt_blocks_json = excluded.prompt_blocks_json, | ||||
| updated_at = excluded.updated_at`, | updated_at = excluded.updated_at`, | ||||
| @@ -428,6 +441,14 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) | |||||
| defaultString(settings.LanguageOutputMode, "EN"), | defaultString(settings.LanguageOutputMode, "EN"), | ||||
| settings.JobPollIntervalSeconds, | settings.JobPollIntervalSeconds, | ||||
| settings.JobPollTimeoutSeconds, | settings.JobPollTimeoutSeconds, | ||||
| provider, | |||||
| model, | |||||
| strings.TrimSpace(settings.LLMBaseURL), | |||||
| strings.TrimSpace(settings.OpenAIAPIKeyEncrypted), | |||||
| strings.TrimSpace(settings.AnthropicAPIKeyEncrypted), | |||||
| strings.TrimSpace(settings.GoogleAPIKeyEncrypted), | |||||
| strings.TrimSpace(settings.XAIAPIKeyEncrypted), | |||||
| strings.TrimSpace(settings.OllamaAPIKeyEncrypted), | |||||
| domain.NormalizeMasterPrompt(settings.MasterPrompt), | domain.NormalizeMasterPrompt(settings.MasterPrompt), | ||||
| promptBlocksRaw, | promptBlocksRaw, | ||||
| time.Now().UTC().Format(time.RFC3339Nano), | time.Now().UTC().Format(time.RFC3339Nano), | ||||
| @@ -437,7 +458,10 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) | |||||
| func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { | func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { | ||||
| row := s.db.QueryRowContext(ctx, ` | row := s.db.QueryRowContext(ctx, ` | ||||
| SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, master_prompt, prompt_blocks_json | |||||
| SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, | |||||
| llm_active_provider, llm_active_model, llm_base_url, | |||||
| openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted, | |||||
| master_prompt, prompt_blocks_json | |||||
| FROM app_settings | FROM app_settings | ||||
| WHERE id = 1`) | WHERE id = 1`) | ||||
| var settings domain.AppSettings | var settings domain.AppSettings | ||||
| @@ -448,6 +472,14 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { | |||||
| &settings.LanguageOutputMode, | &settings.LanguageOutputMode, | ||||
| &settings.JobPollIntervalSeconds, | &settings.JobPollIntervalSeconds, | ||||
| &settings.JobPollTimeoutSeconds, | &settings.JobPollTimeoutSeconds, | ||||
| &settings.LLMActiveProvider, | |||||
| &settings.LLMActiveModel, | |||||
| &settings.LLMBaseURL, | |||||
| &settings.OpenAIAPIKeyEncrypted, | |||||
| &settings.AnthropicAPIKeyEncrypted, | |||||
| &settings.GoogleAPIKeyEncrypted, | |||||
| &settings.XAIAPIKeyEncrypted, | |||||
| &settings.OllamaAPIKeyEncrypted, | |||||
| &settings.MasterPrompt, | &settings.MasterPrompt, | ||||
| &promptBlocksRaw, | &promptBlocksRaw, | ||||
| ); err != nil { | ); err != nil { | ||||
| @@ -460,6 +492,14 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { | |||||
| if len(promptBlocksRaw) > 0 { | if len(promptBlocksRaw) > 0 { | ||||
| _ = json.Unmarshal(promptBlocksRaw, &settings.PromptBlocks) | _ = json.Unmarshal(promptBlocksRaw, &settings.PromptBlocks) | ||||
| } | } | ||||
| settings.LLMActiveProvider = domain.NormalizeLLMProvider(settings.LLMActiveProvider) | |||||
| settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, settings.LLMActiveModel) | |||||
| settings.LLMBaseURL = strings.TrimSpace(settings.LLMBaseURL) | |||||
| settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) | |||||
| settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) | |||||
| settings.GoogleAPIKeyEncrypted = strings.TrimSpace(settings.GoogleAPIKeyEncrypted) | |||||
| settings.XAIAPIKeyEncrypted = strings.TrimSpace(settings.XAIAPIKeyEncrypted) | |||||
| settings.OllamaAPIKeyEncrypted = strings.TrimSpace(settings.OllamaAPIKeyEncrypted) | |||||
| settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks) | settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks) | ||||
| return &settings, nil | return &settings, nil | ||||
| } | } | ||||
| @@ -10,7 +10,7 @@ | |||||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | ||||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | ||||
| <h1>Settings</h1> | <h1>Settings</h1> | ||||
| <p>QC-Settings plus globale Prompt-/Systemsteuerung fuer den spaeteren LLM-Flow.</p> | |||||
| <p>QC-Settings plus LLM- und globale Prompt-/Systemsteuerung fuer den spaeteren LLM-Flow.</p> | |||||
| <table> | <table> | ||||
| <tr><th>QC Base URL</th><td class="mono">{{.QCBaseURL}}</td></tr> | <tr><th>QC Base URL</th><td class="mono">{{.QCBaseURL}}</td></tr> | ||||
| <tr><th>Bearer token configured</th><td>{{if .TokenConfigured}}yes{{else}}no{{end}}</td></tr> | <tr><th>Bearer token configured</th><td>{{if .TokenConfigured}}yes{{else}}no{{end}}</td></tr> | ||||
| @@ -20,6 +20,60 @@ | |||||
| <tr><th>Language output mode</th><td>{{.LanguageOutputMode}}</td></tr> | <tr><th>Language output mode</th><td>{{.LanguageOutputMode}}</td></tr> | ||||
| </table> | </table> | ||||
| <h2>LLM Provider / Modell</h2> | |||||
| <p><small>Phase-A-Grundlage: Provider, Modell, optionale Base URL (Ollama/kompatibel) und provider-spezifische API-Keys.</small></p> | |||||
| <form method="post" action="/settings/llm"> | |||||
| <div> | |||||
| <label>Provider | |||||
| <select id="llm-provider" name="llm_provider"> | |||||
| {{range .LLMProviderOptions}} | |||||
| <option value="{{.Value}}" {{if eq $.LLMActiveProvider .Value}}selected{{end}}>{{.Label}}</option> | |||||
| {{end}} | |||||
| </select> | |||||
| </label> | |||||
| </div> | |||||
| <div> | |||||
| <label>Model | |||||
| <select name="llm_model"> | |||||
| {{range .LLMModelOptions}} | |||||
| <option value="{{.Value}}" {{if eq $.LLMActiveModel .Value}}selected{{end}}>{{.Label}}</option> | |||||
| {{end}} | |||||
| </select> | |||||
| </label> | |||||
| </div> | |||||
| <div id="llm-base-url-wrap" {{if ne .LLMActiveProvider "ollama"}}style="display:none;"{{end}}> | |||||
| <label>Base URL (nur Ollama / kompatible Endpoints) | |||||
| <input type="url" name="llm_base_url" placeholder="http://localhost:11434/v1" value="{{.LLMBaseURL}}"> | |||||
| </label> | |||||
| </div> | |||||
| <div> | |||||
| <label>OpenAI API Key ({{if .OpenAIKeyConfigured}}configured{{else}}not configured{{end}}) | |||||
| <input type="password" name="llm_api_key_openai" placeholder="leer lassen = unveraendert"> | |||||
| </label> | |||||
| </div> | |||||
| <div> | |||||
| <label>Anthropic API Key ({{if .AnthropicKeyConfigured}}configured{{else}}not configured{{end}}) | |||||
| <input type="password" name="llm_api_key_anthropic" placeholder="leer lassen = unveraendert"> | |||||
| </label> | |||||
| </div> | |||||
| <div> | |||||
| <label>Google API Key ({{if .GoogleKeyConfigured}}configured{{else}}not configured{{end}}) | |||||
| <input type="password" name="llm_api_key_google" placeholder="leer lassen = unveraendert"> | |||||
| </label> | |||||
| </div> | |||||
| <div> | |||||
| <label>xAI API Key ({{if .XAIKeyConfigured}}configured{{else}}not configured{{end}}) | |||||
| <input type="password" name="llm_api_key_xai" placeholder="leer lassen = unveraendert"> | |||||
| </label> | |||||
| </div> | |||||
| <div> | |||||
| <label>Ollama API Key (optional; {{if .OllamaKeyConfigured}}configured{{else}}not configured{{end}}) | |||||
| <input type="password" name="llm_api_key_ollama" placeholder="leer lassen = unveraendert"> | |||||
| </label> | |||||
| </div> | |||||
| <button type="submit">LLM-Settings speichern</button> | |||||
| </form> | |||||
| <h2>Globaler Master Prompt</h2> | <h2>Globaler Master Prompt</h2> | ||||
| <p><small>Diese Einstellungen gelten systemweit und werden im normalen Build-/Review-Formular nicht mehr direkt editiert.</small></p> | <p><small>Diese Einstellungen gelten systemweit und werden im normalen Build-/Review-Formular nicht mehr direkt editiert.</small></p> | ||||
| <form method="post" action="/settings/prompt"> | <form method="post" action="/settings/prompt"> | ||||
| @@ -43,6 +97,18 @@ | |||||
| {{end}} | {{end}} | ||||
| <button type="submit">Prompt-Settings speichern</button> | <button type="submit">Prompt-Settings speichern</button> | ||||
| </form> | </form> | ||||
| <script> | |||||
| (function () { | |||||
| var provider = document.getElementById('llm-provider'); | |||||
| var baseUrlWrap = document.getElementById('llm-base-url-wrap'); | |||||
| if (!provider || !baseUrlWrap) return; | |||||
| var syncBaseURLVisibility = function () { | |||||
| baseUrlWrap.style.display = provider.value === 'ollama' ? '' : 'none'; | |||||
| }; | |||||
| provider.addEventListener('change', syncBaseURLVisibility); | |||||
| syncBaseURLVisibility(); | |||||
| })(); | |||||
| </script> | |||||
| </body> | </body> | ||||
| </html> | </html> | ||||
| {{end}} | {{end}} | ||||