From 8223e72fe39fca16b8f97130f6c91c19417f574b Mon Sep 17 00:00:00 2001 From: Anders Holck Date: Wed, 27 May 2026 10:44:56 +0200 Subject: [PATCH] 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 --- frontend/src/App.vue | 158 +++++++++++++++++++++++++++++++++++++++++ internal/api/ai.go | 64 ++++++++++++++++- internal/api/router.go | 1 + 3 files changed, 221 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 914bd5d..686a1c7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -119,6 +119,30 @@

Preview will appear here...

+ +
+
+ + + + +
+
+
+ + +
+
@@ -943,6 +967,12 @@ async function shareFile() { // ─── AI ────────────────────────────────────────────────────────────────────── +const aiChatMode = ref('edit') +const aiChatInput = ref('') +const aiChatResponse = ref('') +const aiChatLoading = ref(false) +const aiVerifyType = ref('') + async function aiVerify() { if (!currentFile.value) return aiResult.value = 'Verifying...' @@ -954,6 +984,55 @@ async function aiVerify() { } } +async function runVerify() { + if (!aiVerifyType.value || !currentFile.value) return + aiChatLoading.value = true + aiChatResponse.value = '' + try { + const res = await api('/api/ai/generate', { path: currentFile.value, action: aiVerifyType.value }) + aiChatResponse.value = res.result || res.feedback || 'No response' + } catch (e) { + aiChatResponse.value = 'AI request failed.' + } + aiChatLoading.value = false + aiVerifyType.value = '' +} + +function aiChatKeydown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendAiChat() + } +} + +async function sendAiChat() { + const msg = aiChatInput.value.trim() + if (!msg) return + aiChatLoading.value = true + aiChatResponse.value = '' + aiChatInput.value = '' + + const action = aiChatMode.value === 'edit' ? 'edit' : 'chat' + try { + const res = await api('/api/ai/chat', { + path: currentFile.value, + content: content.value, + message: msg, + mode: action, + }) + if (action === 'edit' && res.content) { + content.value = res.content + isDirty.value = true + aiChatResponse.value = 'Document updated.' + } else { + aiChatResponse.value = res.result || res.content || 'No response' + } + } catch (e) { + aiChatResponse.value = 'AI request failed. Check MH_AI_ENDPOINT.' + } + aiChatLoading.value = false +} + // ─── Collab ────────────────────────────────────────────────────────────────── function toggleCollab() { @@ -1465,6 +1544,85 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b } .git-btn.dirty { border-color: var(--danger); } +/* ─── AI Chat Panel ───────────────────────────────────────────────────────── */ + +.ai-chat-panel { + height: 15%; + min-height: 100px; + max-height: 200px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + background: var(--bg-secondary); +} +.ai-chat-modes { + display: flex; + gap: 4px; + padding: 4px 8px; + align-items: center; + border-bottom: 1px solid var(--border); +} +.ai-chat-modes button { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 2px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} +.ai-chat-modes button.active { + background: var(--accent); + color: var(--bg-primary); + border-color: var(--accent); +} +.ai-verify-select { + background: var(--bg-primary); + border: 1px solid var(--border); + color: var(--text); + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} +.ai-loading { font-size: 12px; } +.ai-chat-output { + flex: 1; + overflow-y: auto; + padding: 6px 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} +.ai-chat-output p { margin: 0 0 6px; } +.ai-chat-input { + display: flex; + gap: 4px; + padding: 4px 8px; + border-top: 1px solid var(--border); +} +.ai-chat-input textarea { + flex: 1; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + padding: 4px 8px; + font-size: 12px; + font-family: inherit; + resize: none; + outline: none; +} +.ai-send-btn { + background: var(--accent); + color: var(--bg-primary); + border: none; + border-radius: 4px; + padding: 4px 12px; + cursor: pointer; + font-size: 12px; +} + .history-panel, .share-dialog, .ai-panel { position: absolute; right: 0; diff --git a/internal/api/ai.go b/internal/api/ai.go index 74fd20d..2383769 100644 --- a/internal/api/ai.go +++ b/internal/api/ai.go @@ -98,12 +98,21 @@ func (s *Server) handleAIGenerate(w http.ResponseWriter, r *http.Request) { systemPrompt := "You are a helpful assistant. Respond in markdown." switch req.Action { - case "summarize": + 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) @@ -118,7 +127,58 @@ func (s *Server) handleAIGenerate(w http.ResponseWriter, r *http.Request) { files.WriteFile(s.dataDir, userID, filename, response) } - writeJSON(w, 200, map[string]string{"output": 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) { diff --git a/internal/api/router.go b/internal/api/router.go index ce921e4..69f96da 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -65,6 +65,7 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler { // AI mux.HandleFunc("POST /api/ai/verify", s.requireAuth(s.handleAIVerify)) mux.HandleFunc("POST /api/ai/generate", s.requireAuth(s.handleAIGenerate)) + mux.HandleFunc("POST /api/ai/chat", s.requireAuth(s.handleAIChat)) // Build jobs mux.HandleFunc("POST /api/build/submit", s.requireAuth(s.handleBuildSubmit))