Files
markdown-hub/internal/api/ai.go
T
anders 8223e72fe3 AI chat panel with Edit/Chat modes + verify dropdown
- AI chat panel at bottom of editor (all 3 modes)
- Edit mode: AI modifies document directly (no explanations)
- Chat mode: AI answers questions about the document
- Verify dropdown: Spec Review, Grammar & Spelling, Summary
- Enter sends, Shift+Enter for newline
- /api/ai/chat endpoint with edit/chat system prompts
- Grammar and spec verify actions added to /api/ai/generate
2026-05-27 10:44:56 +02:00

234 lines
7.0 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"markdownhub/internal/files"
)
func (s *Server) handleAIVerify(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
content, err := files.ReadFile(s.dataDir, userID, req.Path)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "file not found"})
return
}
aiEndpoint := os.Getenv("MH_AI_ENDPOINT")
aiKey := os.Getenv("MH_AI_API_KEY")
aiModel := os.Getenv("MH_AI_MODEL")
if aiEndpoint == "" {
writeJSON(w, 500, map[string]string{"error": "AI endpoint not configured (MH_AI_ENDPOINT)"})
return
}
if aiModel == "" {
aiModel = "gpt-4"
}
// Call LiteLLM-compatible endpoint
systemPrompt := `You are a technical reviewer. Review the following specification document for:
1. Completeness - are there missing details needed to implement this?
2. Ambiguities - are there unclear requirements?
3. Feasibility - is this technically achievable?
4. Suggestions - any improvements?
Respond with a structured review. End with a clear verdict: READY TO BUILD or NEEDS REVISION.`
response, err := callLLM(aiEndpoint, aiKey, aiModel, systemPrompt, content)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "AI call failed: " + err.Error()})
return
}
ready := strings.Contains(strings.ToUpper(response), "READY TO BUILD")
writeJSON(w, 200, map[string]interface{}{
"feedback": response,
"ready": ready,
})
}
func (s *Server) handleAIGenerate(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Selection string `json:"selection"`
Action string `json:"action"`
OutputFolder string `json:"output_folder"`
}
if err := decodeBody(r, &req); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request"})
return
}
userID := getUserID(r)
var inputText string
if req.Selection != "" {
inputText = req.Selection
} else if req.Path != "" {
content, err := files.ReadFile(s.dataDir, userID, req.Path)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "file not found"})
return
}
inputText = content
}
aiEndpoint := os.Getenv("MH_AI_ENDPOINT")
aiKey := os.Getenv("MH_AI_API_KEY")
aiModel := os.Getenv("MH_AI_MODEL")
if aiEndpoint == "" {
writeJSON(w, 500, map[string]string{"error": "AI endpoint not configured"})
return
}
if aiModel == "" {
aiModel = "gpt-4"
}
systemPrompt := "You are a helpful assistant. Respond in markdown."
switch req.Action {
case "summarize", "summary":
systemPrompt = "Summarize the following text concisely in markdown."
case "prompt":
systemPrompt = "Generate a detailed AI prompt based on the following specification. The prompt should instruct an AI coding agent to implement the project."
case "expand":
systemPrompt = "Expand on the following text with more detail, examples, and explanations. Respond in markdown."
case "grammar":
systemPrompt = "Review the following text for grammar and spelling errors. List each error with the correction. Be concise. Format as a markdown list."
case "spec":
systemPrompt = `You are a technical reviewer. Review the following specification document for:
1. Completeness - are there missing details needed to implement this?
2. Ambiguities - are there unclear requirements?
3. Feasibility - is this technically achievable?
4. Suggestions - any improvements?
Respond with a structured review. End with a clear verdict: READY TO BUILD or NEEDS REVISION.`
}
response, err := callLLM(aiEndpoint, aiKey, aiModel, systemPrompt, inputText)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "AI call failed: " + err.Error()})
return
}
// Optionally save to folder
if req.OutputFolder != "" {
filename := fmt.Sprintf("%s/%s-output.md", req.OutputFolder, req.Action)
files.WriteFile(s.dataDir, userID, filename, response)
}
writeJSON(w, 200, map[string]string{"result": response})
}
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Content string `json:"content"`
Message string `json:"message"`
Mode string `json:"mode"` // "edit" or "chat"
}
if err := decodeBody(r, &req); err != nil || req.Message == "" {
writeJSON(w, 400, map[string]string{"error": "message required"})
return
}
aiEndpoint := os.Getenv("MH_AI_ENDPOINT")
aiKey := os.Getenv("MH_AI_API_KEY")
aiModel := os.Getenv("MH_AI_MODEL")
if aiEndpoint == "" {
writeJSON(w, 500, map[string]string{"error": "AI endpoint not configured"})
return
}
if aiModel == "" {
aiModel = "gpt-4"
}
var systemPrompt string
var userMsg string
if req.Mode == "edit" {
systemPrompt = `You are a document editor. The user will give you a markdown document and an instruction.
Apply the instruction to the document and return ONLY the complete updated document.
Do not add explanations, comments, or markdown code fences around the output.
Preserve the document's existing style and formatting.`
userMsg = "Document:\n\n" + req.Content + "\n\nInstruction: " + req.Message
} else {
systemPrompt = `You are a helpful writing assistant. The user has a markdown document open and is asking a question about it.
Answer concisely in markdown. Reference the document content when relevant.`
userMsg = "Document:\n\n" + req.Content + "\n\nQuestion: " + req.Message
}
response, err := callLLM(aiEndpoint, aiKey, aiModel, systemPrompt, userMsg)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "AI call failed: " + err.Error()})
return
}
if req.Mode == "edit" {
writeJSON(w, 200, map[string]string{"content": response})
} else {
writeJSON(w, 200, map[string]string{"result": response})
}
}
func callLLM(endpoint, apiKey, model, systemPrompt, userContent string) (string, error) {
payload := map[string]interface{}{
"model": model,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userContent},
},
}
body, _ := json.Marshal(payload)
url := strings.TrimRight(endpoint, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, strings.NewReader(string(body)))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("no response from LLM")
}
return result.Choices[0].Message.Content, nil
}