Project-aware assist, link suggestions, project-scoped RAG, semantic search tool, SSE race fix

- Writing assistant: inject project notes as context (definition-tagged first), wikilink suggestions
- Link suggestions: server-side endpoint finds unlinked term occurrences, NoteEditorView sidebar panel
- Project-scoped RAG: ChatView ProjectSelector filters semantic+keyword search to selected project
- Semantic search tool: LLM search_notes upgraded to hybrid semantic (0.40 threshold) + keyword merge
- SSE race condition fix: drain remaining events after stream loop exits in chat.py and notes.py
- RAG_AUTO_SNIPPET raised 800→4000; sidebar include uses full note body; MAX_BODY_CHARS 8000→24000
- Enter-to-submit on writing assistant instruction textareas (note and task editors)
- DiffView: equal-line collapsing with 3-line context around changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:02:54 -05:00
parent 9036dfd931
commit 48f070f773
22 changed files with 767 additions and 113 deletions
+56 -52
View File
@@ -53,7 +53,7 @@ function computeDiff(a: string, b: string): DiffLine[] {
return result;
}
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
const toast = useToastStore();
const state = ref<AssistState>("idle");
@@ -166,6 +166,17 @@ export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
}
}
function _buildProposedFullBody(fullText: string, wholeDoc: boolean): string {
if (wholeDoc) return fullText;
const t = target.value;
if (t) {
let text = fullText;
if (!text.endsWith("\n")) text += "\n";
return bodySnapshot.slice(0, t.startOffset) + text + bodySnapshot.slice(t.endOffset);
}
return fullText;
}
async function submit() {
if (!canSubmit.value) return;
@@ -185,71 +196,64 @@ export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
target_section: targetSection,
instruction: instruction.value,
whole_doc: wholeDoc,
...(noteId?.value ? { note_id: noteId.value } : {}),
...(projectId?.value ? { project_id: projectId.value } : {}),
});
streamHandle = apiSSEStream("/api/notes/assist/stream", async (evt) => {
if (evt.event === "chunk") {
streamingText.value += evt.data.chunk as string;
}
if (evt.event === "done") {
const fullText = (evt.data.full_text as string) || streamingText.value;
proposedText.value = fullText;
// Stream with automatic reconnection on dropped connections
const MAX_RETRIES = 3;
let lastEventId = -1;
let gotDone = false;
if (wholeDoc) {
proposedFullBody.value = fullText;
} else {
// Reconstruct full body with section replaced
const t = target.value;
if (t) {
let text = fullText;
if (!text.endsWith("\n")) text += "\n";
proposedFullBody.value =
bodySnapshot.slice(0, t.startOffset) +
text +
bodySnapshot.slice(t.endOffset);
} else {
proposedFullBody.value = fullText;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) {
if (state.value !== "streaming") break;
await new Promise<void>((r) => setTimeout(r, Math.min(1000 * 2 ** (attempt - 1), 5000)));
if (state.value !== "streaming") break;
}
gotDone = false;
streamHandle = apiSSEStream(
`/api/notes/assist/stream${lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""}`,
(evt) => {
lastEventId = evt.id;
if (evt.event === "chunk") {
streamingText.value += evt.data.chunk as string;
}
}
if (evt.event === "done") {
gotDone = true;
const fullText = (evt.data.full_text as string) || streamingText.value;
proposedText.value = fullText;
proposedFullBody.value = _buildProposedFullBody(fullText, wholeDoc);
state.value = "review";
streamHandle = null;
_saveDraft();
}
if (evt.event === "error") {
gotDone = true;
error.value = evt.data.error as string;
toast.show("Assist failed: " + error.value, "error");
state.value = "idle";
streamHandle = null;
}
},
);
state.value = "review";
streamHandle = null;
await _saveDraft();
}
if (evt.event === "error") {
error.value = evt.data.error as string;
toast.show("Assist failed: " + error.value, "error");
state.value = "idle";
streamHandle = null;
}
});
await streamHandle.done;
await streamHandle.done;
streamHandle = null;
if (gotDone) break;
}
// If still streaming after all retries, treat accumulated text as result
if (state.value === "streaming") {
if (streamingText.value) {
proposedText.value = streamingText.value;
if (wholeDoc) {
proposedFullBody.value = streamingText.value;
} else {
const t = target.value;
if (t) {
let text = streamingText.value;
if (!text.endsWith("\n")) text += "\n";
proposedFullBody.value =
bodySnapshot.slice(0, t.startOffset) +
text +
bodySnapshot.slice(t.endOffset);
} else {
proposedFullBody.value = streamingText.value;
}
}
proposedFullBody.value = _buildProposedFullBody(streamingText.value, wholeDoc);
state.value = "review";
await _saveDraft();
} else {
state.value = "idle";
}
streamHandle = null;
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Request failed";