Stream AI responses (SSE) - text appears as it generates

- Backend streams tokens via Server-Sent Events
- Frontend reads stream with fetch + ReadableStream
- Edit mode: document updates live as tokens arrive
- Chat mode: response text appears progressively
- No more waiting for full generation to complete
This commit is contained in:
2026-05-27 11:03:30 +02:00
parent 63f3c0dec8
commit b020d2e193
2 changed files with 93 additions and 33 deletions
+34 -10
View File
@@ -1014,21 +1014,45 @@ async function sendAiChat() {
const action = aiChatMode.value === 'edit' ? 'edit' : 'chat' const action = aiChatMode.value === 'edit' ? 'edit' : 'chat'
try { try {
const res = await api('/api/ai/chat', { const res = await fetch('/api/ai/chat', {
path: currentFile.value, method: 'POST',
content: content.value, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token.value },
message: msg, body: JSON.stringify({ path: currentFile.value, content: content.value, message: msg, mode: action }),
mode: action,
}) })
if (action === 'edit' && res.content) {
content.value = res.content const reader = res.body.getReader()
const decoder = new TextDecoder()
let fullText = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') break
try {
const token_text = JSON.parse(data)
fullText += token_text
if (action === 'edit') {
content.value = fullText
} else {
aiChatResponse.value = fullText
}
} catch {}
}
}
if (action === 'edit') {
isDirty.value = true isDirty.value = true
aiChatResponse.value = 'Document updated.' aiChatResponse.value = 'Document updated.'
} else {
aiChatResponse.value = res.result || res.content || 'No response'
} }
} catch (e) { } catch (e) {
aiChatResponse.value = 'AI request failed. Check MH_AI_ENDPOINT.' aiChatResponse.value = 'AI request failed.'
} }
aiChatLoading.value = false aiChatLoading.value = false
} }
+59 -23
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -136,7 +137,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
Path string `json:"path"` Path string `json:"path"`
Content string `json:"content"` Content string `json:"content"`
Message string `json:"message"` Message string `json:"message"`
Mode string `json:"mode"` // "edit" or "chat" Mode string `json:"mode"`
} }
if err := decodeBody(r, &req); err != nil || req.Message == "" { if err := decodeBody(r, &req); err != nil || req.Message == "" {
writeJSON(w, 400, map[string]string{"error": "message required"}) writeJSON(w, 400, map[string]string{"error": "message required"})
@@ -154,42 +155,77 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
aiModel = "gpt-4" aiModel = "gpt-4"
} }
var systemPrompt string var systemPrompt, userMsg string
var userMsg string
if req.Mode == "edit" { if req.Mode == "edit" {
systemPrompt = "You are a document editor. The user will give you a markdown document and an instruction. " + systemPrompt = "You are a document editor. The user will give you a markdown document and an instruction. " +
"Apply the instruction and return the COMPLETE updated document. " + "Apply the instruction and return the COMPLETE updated document. " +
"Do not add explanations or wrap in code fences. Return raw markdown only." "Do not add explanations or wrap in code fences. Return raw markdown only."
userMsg = "Document:\n\n" + req.Content + "\n\nInstruction: " + req.Message userMsg = "Document:\n\n" + req.Content + "\n\nInstruction: " + req.Message
} else { } else {
systemPrompt = `You are a helpful writing assistant. The user has a markdown document open and is asking a question about it. systemPrompt = "You are a helpful writing assistant. The user has a markdown document open. Answer concisely in markdown."
Answer concisely in markdown. Reference the document content when relevant.`
userMsg = "Document:\n\n" + req.Content + "\n\nQuestion: " + req.Message userMsg = "Document:\n\n" + req.Content + "\n\nQuestion: " + req.Message
} }
response, err := callLLM(aiEndpoint, aiKey, aiModel, systemPrompt, userMsg) // Stream SSE response
if err != nil { w.Header().Set("Content-Type", "text/event-stream")
writeJSON(w, 500, map[string]string{"error": "AI call failed: " + err.Error()}) w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeJSON(w, 500, map[string]string{"error": "streaming not supported"})
return return
} }
if req.Mode == "edit" { payload := map[string]interface{}{
// Strip markdown code fences if AI wrapped the output "model": aiModel,
response = strings.TrimSpace(response) "messages": []map[string]string{{"role": "system", "content": systemPrompt}, {"role": "user", "content": userMsg}},
if strings.HasPrefix(response, "```") { "temperature": 0.3,
lines := strings.Split(response, "\n") "stream": true,
if len(lines) > 2 { }
lines = lines[1:] // remove opening fence body, _ := json.Marshal(payload)
if strings.TrimSpace(lines[len(lines)-1]) == "```" { url := strings.TrimRight(aiEndpoint, "/") + "/chat/completions"
lines = lines[:len(lines)-1] // remove closing fence aiReq, _ := http.NewRequest("POST", url, strings.NewReader(string(body)))
} aiReq.Header.Set("Content-Type", "application/json")
response = strings.Join(lines, "\n") if aiKey != "" {
aiReq.Header.Set("Authorization", "Bearer "+aiKey)
}
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(aiReq)
if err != nil {
fmt.Fprintf(w, "data: {\"error\":\"AI unreachable\"}\n\n")
flusher.Flush()
return
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := line[6:]
if data == "[DONE]" {
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
break
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if json.Unmarshal([]byte(data), &chunk) == nil && len(chunk.Choices) > 0 {
token := chunk.Choices[0].Delta.Content
if token != "" {
tokenJSON, _ := json.Marshal(token)
fmt.Fprintf(w, "data: %s\n\n", tokenJSON)
flusher.Flush()
} }
} }
writeJSON(w, 200, map[string]string{"content": response})
} else {
writeJSON(w, 200, map[string]string{"result": response})
} }
} }