Refactor AI Assist to background-task + buffer architecture

The assist flow previously tied the entire LLM generation to a single
POST request with no keepalives, causing NS_ERROR_NET_PARTIAL_TRANSFER
in Firefox when Hypercorn closed the connection during gaps between
chunks. This refactor decouples generation into a background task with
a buffer and a separate SSE stream — the same pattern used by chat.

- generation_buffer.py: Widen _buffers to support string keys, add
  create/get/remove_assist_buffer() using "assist:{user_id}" keys,
  fix cleanup log format for string keys
- generation_task.py: Add run_assist_generation() — lightweight
  background task with no DB persistence or title generation
- notes.py: Replace single POST SSE route with POST /api/notes/assist
  (returns 202) + GET /api/notes/assist/stream (SSE with 15s keepalives
  and Last-Event-ID reconnection); 409 if already running
- useAssist.ts: Switch from apiStreamPost to apiPost + apiSSEStream
  two-step pattern with named event mapping and stream handle cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 00:27:21 -05:00
parent 3ec49b7f24
commit a89d25f5d6
5 changed files with 201 additions and 54 deletions
+34 -22
View File
@@ -1,5 +1,5 @@
import { ref, computed, watch, type Ref } from "vue";
import { apiStreamPost } from "@/api/client";
import { apiPost, apiSSEStream, type SSEStreamHandle } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import {
parseMarkdownSections,
@@ -28,6 +28,7 @@ export function useAssist(body: Ref<string>) {
// Snapshot of body at the time a section/selection was chosen
let bodySnapshot = "";
let streamHandle: SSEStreamHandle | null = null;
const target = computed<AssistTarget | null>(() => {
if (customSelection.value) {
@@ -79,6 +80,10 @@ export function useAssist(body: Ref<string>) {
}
function clearSelection() {
if (streamHandle) {
streamHandle.close();
streamHandle = null;
}
selectedSection.value = null;
customSelection.value = null;
instruction.value = "";
@@ -97,28 +102,33 @@ export function useAssist(body: Ref<string>) {
error.value = "";
try {
await apiStreamPost(
"/api/notes/assist",
{
body: body.value,
target_section: target.value.text,
instruction: instruction.value,
},
(data) => {
if (data.chunk) {
streamingText.value += data.chunk as string;
}
if (data.done) {
proposedText.value = (data.full_text as string) || streamingText.value;
state.value = "review";
}
if (data.error) {
error.value = data.error as string;
state.value = "idle";
}
// Step 1: Launch background generation
await apiPost("/api/notes/assist", {
body: body.value,
target_section: target.value.text,
instruction: instruction.value,
});
// Step 2: Tail the SSE buffer
streamHandle = apiSSEStream("/api/notes/assist/stream", (evt) => {
if (evt.event === "chunk") {
streamingText.value += evt.data.chunk as string;
}
);
// If stream ended without a done event, use accumulated text
if (evt.event === "done") {
proposedText.value = (evt.data.full_text as string) || streamingText.value;
state.value = "review";
streamHandle = null;
}
if (evt.event === "error") {
error.value = evt.data.error as string;
state.value = "idle";
streamHandle = null;
}
});
await streamHandle.done;
// Fallback: if stream ended without done/error, use accumulated text
if (state.value === "streaming") {
if (streamingText.value) {
proposedText.value = streamingText.value;
@@ -126,10 +136,12 @@ export function useAssist(body: Ref<string>) {
} else {
state.value = "idle";
}
streamHandle = null;
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Request failed";
state.value = "idle";
streamHandle = null;
}
}