| @@ -58,6 +58,43 @@ Class AdminController_Class | |||||
| <% | <% | ||||
| End Sub | End Sub | ||||
| '--------------------------------------------------------------- | |||||
| ' Action: AIPrompt | |||||
| '--------------------------------------------------------------- | |||||
| Public Sub AIPrompt() | |||||
| m_title = "AI Prompt Settings" | |||||
| Dim promptTemplate : promptTemplate = GetGenerationPromptTemplate() | |||||
| %> | |||||
| <!--#include file="../views/Admin/ai-prompt.asp" --> | |||||
| <% | |||||
| End Sub | |||||
| '--------------------------------------------------------------- | |||||
| ' Action: UpdateAIPrompt | |||||
| '--------------------------------------------------------------- | |||||
| Public Sub UpdateAIPrompt() | |||||
| Dim promptTemplate : promptTemplate = Trim(Request.Form("PromptTemplate")) | |||||
| If Len(promptTemplate) = 0 Then | |||||
| Flash().AddError "Prompt template cannot be empty." | |||||
| Response.Redirect "/admin/ai-prompt" | |||||
| Exit Sub | |||||
| End If | |||||
| On Error Resume Next | |||||
| UpdateAppSetting "AbacusGenerationPrompt", promptTemplate | |||||
| If Err.Number <> 0 Then | |||||
| Flash().AddError "Unable to save prompt template: " & Err.Description | |||||
| Err.Clear | |||||
| On Error GoTo 0 | |||||
| Response.Redirect "/admin/ai-prompt" | |||||
| Exit Sub | |||||
| End If | |||||
| On Error GoTo 0 | |||||
| Flash().Success = "AI prompt template saved." | |||||
| Response.Redirect "/admin/ai-prompt" | |||||
| End Sub | |||||
| '--------------------------------------------------------------- | '--------------------------------------------------------------- | ||||
| ' Action: PublishPost | ' Action: PublishPost | ||||
| '--------------------------------------------------------------- | '--------------------------------------------------------------- | ||||
| @@ -166,10 +203,7 @@ Class AdminController_Class | |||||
| Dim systemPrompt, userPrompt, payload, responseText, parsed, choices, choice, message, content, contentJson | Dim systemPrompt, userPrompt, payload, responseText, parsed, choices, choice, message, content, contentJson | ||||
| systemPrompt = "You write clear, engaging blog post content for a classic ASP blog. Return only valid JSON with two keys: summary and body. Summary must be 1 to 2 sentences. Body must be 3 to 5 short paragraphs separated by blank lines. Do not use markdown fences, bullets, or code blocks." | systemPrompt = "You write clear, engaging blog post content for a classic ASP blog. Return only valid JSON with two keys: summary and body. Summary must be 1 to 2 sentences. Body must be 3 to 5 short paragraphs separated by blank lines. Do not use markdown fences, bullets, or code blocks." | ||||
| userPrompt = "Create blog content for this post title: " & SafeText(post.Title) & vbCrLf & _ | |||||
| "Existing summary: " & SafeText(post.Summary) & vbCrLf & _ | |||||
| "Existing body: " & SafeText(post.Body) & vbCrLf & _ | |||||
| "Keep the title unchanged. Make the content readable and helpful for a general audience." | |||||
| userPrompt = BuildGenerationPrompt(post) | |||||
| payload = "{""model"":""" & JsonEscape(modelName) & """,""messages"":[{""role"":""system"",""content"":""" & JsonEscape(systemPrompt) & """},{""role"":""user"",""content"":""" & JsonEscape(userPrompt) & """}],""temperature"":0.7}" | payload = "{""model"":""" & JsonEscape(modelName) & """,""messages"":[{""role"":""system"",""content"":""" & JsonEscape(systemPrompt) & """},{""role"":""user"",""content"":""" & JsonEscape(userPrompt) & """}],""temperature"":0.7}" | ||||
| @@ -235,14 +269,6 @@ Class AdminController_Class | |||||
| End If | End If | ||||
| End Function | End Function | ||||
| Private Function SafeText(ByVal value) | |||||
| If IsNull(value) Or IsEmpty(value) Then | |||||
| SafeText = "" | |||||
| Else | |||||
| SafeText = CStr(value) | |||||
| End If | |||||
| End Function | |||||
| End Class | End Class | ||||
| Dim AdminController_Class__Singleton | Dim AdminController_Class__Singleton | ||||
| @@ -53,7 +53,7 @@ Class CommentsController_Class | |||||
| Dim slug : slug = ResolvePostSlug(comment.PostID) | Dim slug : slug = ResolvePostSlug(comment.PostID) | ||||
| If Len(slug) > 0 Then | If Len(slug) > 0 Then | ||||
| Response.Redirect "/posts/" & Server.URLEncode(slug) | |||||
| Response.Redirect PostUrl(slug) | |||||
| Else | Else | ||||
| Response.Redirect "/posts" | Response.Redirect "/posts" | ||||
| End If | End If | ||||
| @@ -82,12 +82,13 @@ Class CommentsController_Class | |||||
| Private Function ResolvePostSlug(ByVal postID) | Private Function ResolvePostSlug(ByVal postID) | ||||
| Dim slug : slug = Trim(Request.Form("Slug")) | Dim slug : slug = Trim(Request.Form("Slug")) | ||||
| If Len(slug) = 0 Then slug = Trim(Request.Form("PostSlug")) | If Len(slug) = 0 Then slug = Trim(Request.Form("PostSlug")) | ||||
| slug = NormalizeSlug(slug) | |||||
| If Len(slug) = 0 And CLng(postID) > 0 Then | If Len(slug) = 0 And CLng(postID) > 0 Then | ||||
| Dim post | Dim post | ||||
| On Error Resume Next | On Error Resume Next | ||||
| Set post = PostsRepository().FindByID(postID) | Set post = PostsRepository().FindByID(postID) | ||||
| If Err.Number = 0 Then slug = CStr(post.Slug) | |||||
| If Err.Number = 0 Then slug = NormalizeSlug(post.Slug) | |||||
| Err.Clear | Err.Clear | ||||
| On Error GoTo 0 | On Error GoTo 0 | ||||
| End If | End If | ||||
| @@ -45,12 +45,19 @@ Class PostsController_Class | |||||
| <% | <% | ||||
| End Sub | End Sub | ||||
| '--------------------------------------------------------------- | |||||
| ' Action: Show | |||||
| '--------------------------------------------------------------- | |||||
| '--------------------------------------------------------------- | |||||
| ' Action: Show | |||||
| '--------------------------------------------------------------- | |||||
| Public Sub Show(ByVal slug) | Public Sub Show(ByVal slug) | ||||
| Dim requestedSlug : requestedSlug = Trim(CStr(slug)) | |||||
| Dim canonicalSlug : canonicalSlug = NormalizeSlug(requestedSlug) | |||||
| Dim matches | Dim matches | ||||
| Set matches = PostsRepository().Find(Array("Slug", slug, "IsPublished", 1), Empty) | |||||
| Set matches = PostsRepository().Find(Array("Slug", requestedSlug, "IsPublished", 1), Empty) | |||||
| If matches.Count = 0 And canonicalSlug <> requestedSlug Then | |||||
| Set matches = PostsRepository().Find(Array("Slug", canonicalSlug, "IsPublished", 1), Empty) | |||||
| End If | |||||
| If matches.Count = 0 Then | If matches.Count = 0 Then | ||||
| Response.Status = "404 Not Found" | Response.Status = "404 Not Found" | ||||
| @@ -63,6 +70,10 @@ Class PostsController_Class | |||||
| Dim post | Dim post | ||||
| Set post = matches.Front() | Set post = matches.Front() | ||||
| If Len(canonicalSlug) > 0 And canonicalSlug <> requestedSlug Then | |||||
| Response.Redirect PostUrl(canonicalSlug) | |||||
| End If | |||||
| Dim comments | Dim comments | ||||
| Set comments = CommentsRepository().Find(Array("PostID", post.PostID, "IsApproved", 1), "CreatedDate") | Set comments = CommentsRepository().Find(Array("PostID", post.PostID, "IsApproved", 1), "CreatedDate") | ||||
| %> | %> | ||||
| @@ -0,0 +1,25 @@ | |||||
| <div class="row"> | |||||
| <div class="col-lg-10 col-xl-8"> | |||||
| <div class="card shadow-sm"> | |||||
| <div class="card-body"> | |||||
| <h1 class="h3 mb-2">AI Prompt Settings</h1> | |||||
| <p class="text-muted mb-4">Edit the template used when generating AI post content.</p> | |||||
| <form method="post" action="<%= AdminAIPromptUpdateUrl() %>"> | |||||
| <div class="mb-3"> | |||||
| <label class="form-label" for="PromptTemplate">Prompt Template</label> | |||||
| <textarea class="form-control" id="PromptTemplate" name="PromptTemplate" rows="14" spellcheck="false"><%= H(promptTemplate) %></textarea> | |||||
| <div class="form-text"> | |||||
| Placeholders: <code>{TITLE}</code>, <code>{SUMMARY}</code>, <code>{BODY}</code> | |||||
| </div> | |||||
| </div> | |||||
| <div class="d-flex flex-wrap gap-2"> | |||||
| <button class="btn btn-primary" type="submit">Save Prompt</button> | |||||
| <a class="btn btn-outline-secondary" href="<%= AdminUrl() %>">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| @@ -22,4 +22,12 @@ | |||||
| </div> | </div> | ||||
| </a> | </a> | ||||
| </div> | </div> | ||||
| <div class="col-md-6"> | |||||
| <a class="card shadow-sm text-decoration-none h-100" href="<%= AdminUrl() %>/ai-prompt"> | |||||
| <div class="card-body"> | |||||
| <h2 class="h5 mb-1">AI Prompt</h2> | |||||
| <p class="text-muted mb-0">Edit the prompt used to generate AI post content.</p> | |||||
| </div> | |||||
| </a> | |||||
| </div> | |||||
| </div> | </div> | ||||
| @@ -95,6 +95,76 @@ Public Function GetSecureSetting(key, envName) | |||||
| GetSecureSetting = "" | GetSecureSetting = "" | ||||
| End Function | End Function | ||||
| Public Function GetGenerationPromptTemplate() | |||||
| Dim prompt | |||||
| prompt = Trim(CStr(GetAppSetting("AbacusGenerationPrompt"))) | |||||
| If Len(prompt) = 0 Or LCase(prompt) = "nothing" Then | |||||
| prompt = "You write clear, engaging blog post content for a classic ASP blog. Return only valid JSON with two keys: summary and body. Summary must be 1 to 2 sentences. Body must be 3 to 5 short paragraphs separated by blank lines. Do not use markdown fences, bullets, or code blocks." & vbCrLf & vbCrLf & _ | |||||
| "Create blog content for this post title: {TITLE}" & vbCrLf & _ | |||||
| "Existing summary: {SUMMARY}" & vbCrLf & _ | |||||
| "Existing body: {BODY}" & vbCrLf & _ | |||||
| "Keep the title unchanged. Make the content readable and helpful for a general audience." | |||||
| End If | |||||
| GetGenerationPromptTemplate = prompt | |||||
| End Function | |||||
| Public Function BuildGenerationPrompt(ByRef post) | |||||
| Dim template | |||||
| template = GetGenerationPromptTemplate() | |||||
| template = Replace(template, "{TITLE}", SafePromptText(post.Title)) | |||||
| template = Replace(template, "{SUMMARY}", SafePromptText(post.Summary)) | |||||
| template = Replace(template, "{BODY}", SafePromptText(post.Body)) | |||||
| BuildGenerationPrompt = template | |||||
| End Function | |||||
| Public Function SafePromptText(ByVal value) | |||||
| If IsNull(value) Or IsEmpty(value) Then | |||||
| SafePromptText = "" | |||||
| Else | |||||
| SafePromptText = CStr(value) | |||||
| End If | |||||
| End Function | |||||
| Public Function UpdateAppSetting(key, value) | |||||
| Dim xml, nodes, node, appSettings, found | |||||
| Set xml = Server.CreateObject("Microsoft.XMLDOM") | |||||
| xml.async = False | |||||
| xml.preserveWhiteSpace = True | |||||
| xml.Load Server.MapPath("web.config") | |||||
| If xml.parseError.errorCode <> 0 Then | |||||
| Err.Raise 1, "UpdateAppSetting", "Unable to load web.config: " & xml.parseError.reason | |||||
| End If | |||||
| Set nodes = xml.selectNodes("//appSettings/add[@key='" & key & "']") | |||||
| found = False | |||||
| If Not (nodes Is Nothing) Then | |||||
| If nodes.Length > 0 Then | |||||
| Set node = nodes.Item(0) | |||||
| node.setAttribute "value", value | |||||
| found = True | |||||
| End If | |||||
| End If | |||||
| If Not found Then | |||||
| Set appSettings = xml.selectSingleNode("//appSettings") | |||||
| If appSettings Is Nothing Then | |||||
| Err.Raise 1, "UpdateAppSetting", "<appSettings> section not found in web.config." | |||||
| End If | |||||
| Set node = xml.createElement("add") | |||||
| node.setAttribute "key", key | |||||
| node.setAttribute "value", value | |||||
| appSettings.appendChild node | |||||
| End If | |||||
| xml.Save Server.MapPath("web.config") | |||||
| Application.Contents.Remove("AppSetting_" & key) | |||||
| UpdateAppSetting = True | |||||
| End Function | |||||
| Public Sub ShowServerVariables | Public Sub ShowServerVariables | ||||
| Dim varName, htmlTable | Dim varName, htmlTable | ||||
| htmlTable = "<table border='1' cellspacing='0' cellpadding='5'>" | htmlTable = "<table border='1' cellspacing='0' cellpadding='5'>" | ||||
| @@ -183,6 +253,31 @@ Function TrimQueryParams(rawPath) | |||||
| End If | End If | ||||
| End Function | End Function | ||||
| Function DecodeUrlPath(ByVal rawPath) | |||||
| Dim current, previous | |||||
| current = Trim(CStr(rawPath)) | |||||
| On Error Resume Next | |||||
| Do | |||||
| previous = current | |||||
| current = Server.URLDecode(current) | |||||
| If Err.Number <> 0 Then | |||||
| Err.Clear | |||||
| current = previous | |||||
| Exit Do | |||||
| End If | |||||
| Loop While current <> previous | |||||
| On Error GoTo 0 | |||||
| current = Replace(current, "%252D", "-") | |||||
| current = Replace(current, "%252d", "-") | |||||
| current = Replace(current, "%2D", "-") | |||||
| current = Replace(current, "%2d", "-") | |||||
| current = Replace(current, "%25", "%") | |||||
| DecodeUrlPath = current | |||||
| End Function | |||||
| Sub Destroy(o) | Sub Destroy(o) | ||||
| if isobject(o) then | if isobject(o) then | ||||
| if not o is nothing then | if not o is nothing then | ||||
| @@ -252,7 +347,7 @@ Function CategoryDeleteUrl(ByVal categoryId) | |||||
| End Function | End Function | ||||
| Function PostUrl(ByVal slug) | Function PostUrl(ByVal slug) | ||||
| PostUrl = "/posts/" & Server.URLEncode(CStr(slug)) | |||||
| PostUrl = "/posts/" & Server.URLEncode(NormalizeSlug(slug)) | |||||
| End Function | End Function | ||||
| Function PostsUrl() | Function PostsUrl() | ||||
| @@ -287,6 +382,14 @@ Function AdminPostAIUrl(ByVal postId) | |||||
| AdminPostAIUrl = "/admin/posts/" & Server.URLEncode(CStr(postId)) & "/ai" | AdminPostAIUrl = "/admin/posts/" & Server.URLEncode(CStr(postId)) & "/ai" | ||||
| End Function | End Function | ||||
| Function AdminAIPromptUrl() | |||||
| AdminAIPromptUrl = "/admin/ai-prompt" | |||||
| End Function | |||||
| Function AdminAIPromptUpdateUrl() | |||||
| AdminAIPromptUpdateUrl = "/admin/ai-prompt" | |||||
| End Function | |||||
| Function AdminUrl() | Function AdminUrl() | ||||
| AdminUrl = "/admin" | AdminUrl = "/admin" | ||||
| End Function | End Function | ||||
| @@ -295,6 +398,36 @@ Function CommentsUrl() | |||||
| CommentsUrl = "/comments" | CommentsUrl = "/comments" | ||||
| End Function | End Function | ||||
| Function NormalizeSlug(ByVal slug) | |||||
| Dim current, previous | |||||
| current = Trim(CStr(slug)) | |||||
| If Len(current) = 0 Then | |||||
| NormalizeSlug = "" | |||||
| Exit Function | |||||
| End If | |||||
| On Error Resume Next | |||||
| Do | |||||
| previous = current | |||||
| current = Server.URLDecode(current) | |||||
| If Err.Number <> 0 Then | |||||
| Err.Clear | |||||
| current = previous | |||||
| Exit Do | |||||
| End If | |||||
| Loop While current <> previous | |||||
| On Error GoTo 0 | |||||
| current = Replace(current, "%252D", "-") | |||||
| current = Replace(current, "%252d", "-") | |||||
| current = Replace(current, "%2D", "-") | |||||
| current = Replace(current, "%2d", "-") | |||||
| current = Replace(current, "%25", "%") | |||||
| NormalizeSlug = current | |||||
| End Function | |||||
| '======================================================================================================================= | '======================================================================================================================= | ||||
| ' Adapted from Tolerable library | ' Adapted from Tolerable library | ||||
| @@ -165,6 +165,19 @@ Private Function NormalizePath(path) | |||||
| cleaned = Left(cleaned, Len(cleaned) - 1) | cleaned = Left(cleaned, Len(cleaned) - 1) | ||||
| Loop | Loop | ||||
| On Error Resume Next | |||||
| Do | |||||
| Dim decoded | |||||
| decoded = Server.URLDecode(cleaned) | |||||
| If Err.Number <> 0 Then | |||||
| Err.Clear | |||||
| Exit Do | |||||
| End If | |||||
| If decoded = cleaned Then Exit Do | |||||
| cleaned = LCase(decoded) | |||||
| Loop | |||||
| On Error GoTo 0 | |||||
| NormalizePath = cleaned | NormalizePath = cleaned | ||||
| End Function | End Function | ||||
| @@ -33,11 +33,13 @@ | |||||
| router.AddRoute "GET", "/admin", "AdminController", "Index" | router.AddRoute "GET", "/admin", "AdminController", "Index" | ||||
| router.AddRoute "GET", "/admin/posts", "AdminController", "Posts" | router.AddRoute "GET", "/admin/posts", "AdminController", "Posts" | ||||
| router.AddRoute "GET", "/admin/categories", "AdminController", "Categories" | router.AddRoute "GET", "/admin/categories", "AdminController", "Categories" | ||||
| router.AddRoute "GET", "/admin/ai-prompt", "AdminController", "AIPrompt" | |||||
| router.AddRoute "POST", "/admin/ai-prompt", "AdminController", "UpdateAIPrompt" | |||||
| router.AddRoute "POST", "/admin/posts/{id}/publish", "AdminController", "PublishPost" | router.AddRoute "POST", "/admin/posts/{id}/publish", "AdminController", "PublishPost" | ||||
| router.AddRoute "POST", "/admin/posts/{id}/unpublish", "AdminController", "UnpublishPost" | router.AddRoute "POST", "/admin/posts/{id}/unpublish", "AdminController", "UnpublishPost" | ||||
| router.AddRoute "POST", "/admin/posts/{id}/ai", "AdminController", "GenerateAIContent" | router.AddRoute "POST", "/admin/posts/{id}/ai", "AdminController", "GenerateAIContent" | ||||
| ' Dispatch the request (resolves route and executes controller action) | ' Dispatch the request (resolves route and executes controller action) | ||||
| MVC.DispatchRequest Request.ServerVariables("REQUEST_METHOD"), _ | |||||
| TrimQueryParams(Request.ServerVariables("HTTP_X_ORIGINAL_URL")) | |||||
| MVC.DispatchRequest Request.ServerVariables("REQUEST_METHOD"), _ | |||||
| TrimQueryParams(DecodeUrlPath(Request.ServerVariables("HTTP_X_ORIGINAL_URL"))) | |||||
| %> | %> | ||||
| @@ -48,6 +48,9 @@ | |||||
| <!-- Model name to send to the Abacus RouteLLM API --> | <!-- Model name to send to the Abacus RouteLLM API --> | ||||
| <add key="AbacusModel" value="route-llm" /> | <add key="AbacusModel" value="route-llm" /> | ||||
| <!-- Editable prompt template used to generate AI post content --> | |||||
| <add key="AbacusGenerationPrompt" value="You write clear, engaging blog post content for a classic ASP blog. Return only valid JSON with two keys: summary and body. Summary must be 1 to 2 sentences. Body must be 3 to 5 short paragraphs separated by blank lines. Do not use markdown fences, bullets, or code blocks. Create blog content for this post title: {TITLE} Existing summary: {SUMMARY} Existing body: {BODY} Keep the title unchanged. Make the content readable and helpful for a general audience." /> | |||||
| </appSettings> | </appSettings> | ||||
| <system.webServer> | <system.webServer> | ||||
Powered by TurnKey Linux.