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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user