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
+66 -8
View File
@@ -6,9 +6,52 @@ const props = defineProps<{
diff: DiffLine[];
}>();
const CONTEXT_LINES = 3;
const MIN_COLLAPSE = 6; // Only collapse if there are more than this many consecutive equal lines
type CollapseMarker = { type: 'collapse'; count: number };
type DisplayItem = DiffLine | CollapseMarker;
const insertions = computed(() => props.diff.filter(l => l.type === 'insert').length);
const deletions = computed(() => props.diff.filter(l => l.type === 'delete').length);
const displayItems = computed<DisplayItem[]>(() => {
const d = props.diff;
if (!d.length) return [];
// Mark which indices are within CONTEXT_LINES of a change
const shown = new Set<number>();
for (let i = 0; i < d.length; i++) {
if (d[i].type !== 'equal') {
for (let c = Math.max(0, i - CONTEXT_LINES); c <= Math.min(d.length - 1, i + CONTEXT_LINES); c++) {
shown.add(c);
}
}
}
// If nothing changed, show all
if (shown.size === 0) return d;
const result: DisplayItem[] = [];
let i = 0;
while (i < d.length) {
if (shown.has(i)) {
result.push(d[i]);
i++;
} else {
let count = 0;
while (i < d.length && !shown.has(i)) { count++; i++; }
if (count >= MIN_COLLAPSE) {
result.push({ type: 'collapse', count });
} else {
// Too short to collapse — show as-is
for (let k = i - count; k < i; k++) result.push(d[k]);
}
}
}
return result;
});
function markerFor(type: DiffLine['type']): string {
if (type === 'insert') return '+';
if (type === 'delete') return '';
@@ -24,14 +67,19 @@ function markerFor(type: DiffLine['type']): string {
</div>
<div class="diff-scroll">
<div v-if="!diff.length" class="diff-empty">No changes.</div>
<div
v-for="(line, i) in diff"
:key="i"
:class="['diff-line', `diff-${line.type}`]"
>
<span class="diff-marker">{{ markerFor(line.type) }}</span>
<span class="diff-text">{{ line.text }}</span>
</div>
<template v-for="(item, i) in displayItems" :key="i">
<div v-if="item.type === 'collapse'" class="diff-line diff-collapse">
<span class="diff-marker"></span>
<span class="diff-text">{{ item.count }} unchanged line{{ item.count !== 1 ? 's' : '' }}</span>
</div>
<div
v-else
:class="['diff-line', `diff-${item.type}`]"
>
<span class="diff-marker">{{ markerFor(item.type) }}</span>
<span class="diff-text">{{ item.text }}</span>
</div>
</template>
</div>
</div>
</template>
@@ -102,6 +150,16 @@ function markerFor(type: DiffLine['type']): string {
color: var(--color-text-muted);
}
.diff-collapse {
color: var(--color-text-muted);
opacity: 0.6;
font-style: italic;
border-top: 1px dashed var(--color-border);
border-bottom: 1px dashed var(--color-border);
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.diff-marker {
flex-shrink: 0;
width: 1rem;
+3 -2
View File
@@ -8,6 +8,7 @@ interface NoteVersion {
id: number;
note_id: number;
title: string;
tags: string[];
body?: string;
created_at: string;
}
@@ -18,7 +19,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: 'restore', body: string): void;
(e: 'restore', body: string, tags: string[]): void;
(e: 'close'): void;
}>();
@@ -101,7 +102,7 @@ async function selectVersionItem(v: NoteVersion) {
function restore() {
if (!selectedVersion.value?.body) return;
emit('restore', selectedVersion.value.body);
emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []);
}
onMounted(loadVersions);
+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";
+34
View File
@@ -155,6 +155,7 @@ export const useChatStore = defineStore("chat", () => {
think = false,
contextNoteTitle?: string,
excludeNoteIds?: number[],
ragProjectId?: number | null,
) {
if (!currentConversation.value) return;
@@ -204,6 +205,7 @@ export const useChatStore = defineStore("chat", () => {
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
think,
rag_project_id: ragProjectId ?? undefined,
},
);
assistantMessageId = resp.assistant_message_id;
@@ -348,6 +350,37 @@ export const useChatStore = defineStore("chat", () => {
}
}
async function reconnectIfGenerating(convId: number): Promise<void> {
// Skip if a stream is already active for this conversation
if (isStreamingConv(convId)) return;
const conv = currentConversation.value;
if (!conv || conv.id !== convId) return;
// Find the last assistant message still in generating state
const lastMsg = [...conv.messages].reverse().find(
(m) => m.role === "assistant" && m.status === "generating"
);
if (!lastMsg) return;
// Remove the partial message — the done event will re-add the final version
conv.messages = conv.messages.filter((m) => m.id !== lastMsg.id);
const s = _getOrInitStream(convId);
s.streaming = true;
s.content = "";
s.thinking = "";
s.toolCalls = [];
s.status = "Reconnecting...";
s.pendingTool = null;
s.contextMeta = null;
// Reconnect from the beginning — the buffer replays all buffered events.
// If the buffer is gone (>60s stale), _streamGeneration will exhaust retries
// then fall back to fetchConversation to show the final saved content.
await _streamGeneration(convId, lastMsg.id);
}
async function confirmTool(confirmed: boolean) {
const convId = currentConversation.value?.id;
if (!convId) return;
@@ -461,6 +494,7 @@ export const useChatStore = defineStore("chat", () => {
deleteConversation,
updateTitle,
sendMessage,
reconnectIfGenerating,
confirmTool,
cancelGeneration,
saveMessageAsNote,
+1
View File
@@ -29,6 +29,7 @@ export interface NoteVersion {
id: number;
note_id: number;
title: string;
tags: string[];
body?: string;
created_at: string;
}
+39
View File
@@ -9,6 +9,7 @@ import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import type { Note } from "@/types/note";
import ProjectSelector from "@/components/ProjectSelector.vue";
const route = useRoute();
const router = useRouter();
@@ -46,6 +47,9 @@ const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null
// Note IDs excluded from auto-injection on next message
const excludedNoteIds = ref<number[]>([]);
// Project scope for RAG — when set, semantic & keyword search is restricted to this project
const ragProjectId = ref<number | null>(null);
let prevConvId: number | null = null;
const convId = computed(() => {
@@ -89,6 +93,8 @@ onMounted(async () => {
if (store.currentConversation?.id !== convId.value) {
await store.fetchConversation(convId.value);
}
// Reconnect if the last assistant message is still generating (e.g. after page refresh)
store.reconnectIfGenerating(convId.value);
} else {
await startNewConversation();
}
@@ -116,6 +122,8 @@ watch(convId, async (newId) => {
if (store.currentConversation?.id !== newId && !store.isStreamingConv(newId)) {
await store.fetchConversation(newId);
}
// Reconnect if a prior stream died while the backend was still generating
store.reconnectIfGenerating(newId);
scrollToBottom();
} else {
await startNewConversation();
@@ -220,6 +228,7 @@ async function sendMessage() {
true, // enable thinking in the full chat view
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
ragProjectId.value,
);
sending.value = false;
@@ -404,6 +413,14 @@ onUnmounted(() => {
<button class="btn-new-conv" @click="newConversation">
+ New Chat
</button>
<!-- RAG project scope -->
<div class="rag-scope-section">
<label class="rag-scope-label">Scope notes to project</label>
<ProjectSelector v-model="ragProjectId" />
<p v-if="ragProjectId" class="rag-scope-hint">RAG search restricted to this project</p>
</div>
<div class="conv-list">
<div
v-for="conv in store.conversations"
@@ -713,6 +730,28 @@ onUnmounted(() => {
opacity: 0.9;
}
.rag-scope-section {
padding: 0.5rem 0.75rem 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.rag-scope-label {
display: block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.3rem;
}
.rag-scope-hint {
margin: 0.3rem 0 0;
font-size: 0.7rem;
color: var(--color-primary);
font-style: italic;
}
.conv-list {
flex: 1;
overflow-y: auto;
+155 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
@@ -43,7 +43,7 @@ const isEditing = computed(() => noteId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist — pass noteId for draft persistence
const assist = useAssist(body, noteId);
const assist = useAssist(body, noteId, projectId);
// Scope selector options: "Whole document" + one per section
const scopeOptions = computed(() => {
@@ -155,6 +155,74 @@ function dismissTagSuggestions() {
appliedTags.value = new Set();
}
// ── Link Suggestions ────────────────────────────────────────────────────────
interface LinkSuggestion {
note_id: number;
title: string;
count: number;
}
const linkSuggestions = ref<LinkSuggestion[]>([]);
let linkCheckTimer: ReturnType<typeof setTimeout> | null = null;
async function fetchLinkSuggestions() {
if (!projectId.value || !body.value || !noteId.value) {
linkSuggestions.value = [];
return;
}
try {
const res = await apiPost<{ suggestions: LinkSuggestion[] }>('/api/notes/link-suggestions', {
body: body.value,
project_id: projectId.value,
exclude_note_id: noteId.value,
});
linkSuggestions.value = res.suggestions;
} catch {
linkSuggestions.value = [];
}
}
function scheduleLinkCheck() {
if (linkCheckTimer) clearTimeout(linkCheckTimer);
linkCheckTimer = setTimeout(fetchLinkSuggestions, 2000);
}
watch(body, () => {
if (projectId.value) scheduleLinkCheck();
});
watch(projectId, (pid) => {
if (pid) fetchLinkSuggestions();
else linkSuggestions.value = [];
});
/** Replace unlinked occurrences of title in body with [[title]]. */
function applyWikilink(text: string, title: string): string {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = text.split(/(\[\[[^\]]*\]\])/);
const pattern = new RegExp(`\\b${escaped}\\b`, 'gi');
return parts.map((part, i) =>
i % 2 === 0 ? part.replace(pattern, `[[${title}]]`) : part
).join('');
}
function applyLink(title: string) {
body.value = applyWikilink(body.value, title);
markDirty();
linkSuggestions.value = linkSuggestions.value.filter(s => s.title !== title);
}
function applyAllLinks() {
let text = body.value;
for (const s of linkSuggestions.value) {
text = applyWikilink(text, s.title);
}
body.value = text;
markDirty();
linkSuggestions.value = [];
}
// Track saved state for dirty detection
let savedTitle = "";
let savedBody = "";
@@ -199,6 +267,9 @@ onMounted(async () => {
} catch {
// No draft — normal
}
// Initial link suggestions
fetchLinkSuggestions();
}
});
@@ -306,6 +377,9 @@ function onBeforeUnload(e: BeforeUnloadEvent) {
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
// Close any in-flight assist SSE stream when navigating away
onUnmounted(() => assist.clearSelection());
</script>
<template>
@@ -425,6 +499,21 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</template>
</div>
<!-- Link Suggestions -->
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
<div class="sb-label-row">
<span class="sb-label">Link Suggestions</span>
<button class="btn-link-all" @click="applyAllLinks">Link all</button>
</div>
<div class="link-suggest-list">
<div v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-item">
<span class="link-suggest-title">[[{{ s.title }}]]</span>
<span class="link-suggest-count">×{{ s.count }}</span>
<button class="btn-apply-link" @click="applyLink(s.title)">Link</button>
</div>
</div>
</div>
<div class="sb-divider"></div>
<!-- Writing Assistant (always visible) -->
@@ -451,6 +540,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
placeholder="What should I do?"
class="assist-instruction"
rows="3"
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
></textarea>
<div class="assist-input-actions">
<button
@@ -500,7 +590,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
v-if="showHistory && noteId"
:note-id="noteId"
:current-body="body"
@restore="body = $event; markDirty()"
@restore="(b: string, t: string[]) => { body = b; tags = t; markDirty(); }"
@close="showHistory = false"
/>
@@ -648,6 +738,68 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
align-items: center;
}
/* Link Suggestions */
.link-suggest-field { gap: 0.4rem; }
.sb-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-link-all {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
background: none;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
cursor: pointer;
font-family: inherit;
}
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
.link-suggest-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.link-suggest-item {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
}
.link-suggest-title {
flex: 1;
font-family: monospace;
color: var(--color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-suggest-count {
color: var(--color-text-muted);
font-size: 0.72rem;
flex-shrink: 0;
}
.btn-apply-link {
flex-shrink: 0;
font-size: 0.72rem;
padding: 0.1rem 0.4rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
color: var(--color-text-secondary);
}
.btn-apply-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* Writing Assistant section */
.assist-section {
display: flex;
+2 -1
View File
@@ -107,7 +107,7 @@ const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist
const assist = useAssist(body);
const assist = useAssist(body, taskId, projectId);
const assistLabel = computed(() => {
if (assist.isProofreading.value) return "Proofreading document...";
@@ -681,6 +681,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
placeholder="What should I do with this section?"
class="assist-instruction"
rows="3"
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
></textarea>
<div class="assist-input-actions">
<button class="btn-generate" @click="assist.submit()" :disabled="!assist.canSubmit.value">Generate</button>