Files
FabledGrimoire/frontend/src/views/NoteEditorView.vue
T
bvandeusenandClaude Sonnet 4.6 48f070f773 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>
2026-03-06 14:02:54 -05:00

852 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist, type NoteDraft } from "@/composables/useAssist";
import { apiPost, apiGet } from "@/api/client";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
import DiffView from "@/components/DiffView.vue";
import HistoryPanel from "@/components/HistoryPanel.vue";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
const toast = useToastStore();
const title = ref("");
const body = ref("");
const tags = ref<string[]>([]);
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const sidebarOpen = ref(true);
const showHistory = ref(false);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null;
});
const noteId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => noteId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist — pass noteId for draft persistence
const assist = useAssist(body, noteId, projectId);
// Scope selector options: "Whole document" + one per section
const scopeOptions = computed(() => {
const opts: Array<{ value: string; label: string }> = [
{ value: '__document__', label: 'Whole document' },
];
for (const section of assist.sections.value) {
opts.push({
value: String(assist.sections.value.indexOf(section)),
label: section.heading || '(preamble)',
});
}
return opts;
});
const scopeSelectValue = computed({
get() {
if (assist.scopeMode.value === 'document') return '__document__';
if (assist.selectedSection.value) {
const idx = assist.sections.value.indexOf(assist.selectedSection.value);
return idx >= 0 ? String(idx) : '__document__';
}
return '__document__';
},
set(val: string) {
if (val === '__document__') {
assist.scopeMode.value = 'document';
assist.selectedSection.value = null;
} else {
const idx = Number(val);
const section = assist.sections.value[idx];
if (section) {
assist.scopeMode.value = 'section';
assist.selectSection(section);
}
}
},
});
// Floating inline assist button
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<{ start: number; end: number } | null>(null);
const instructionRef = ref<HTMLTextAreaElement | null>(null);
function onSelectionChange(payload: { text: string; start: number; end: number }) {
if (payload.text.trim().length > 0) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
floatingAssist.value = { show: true, top: rect.top - 44, left: rect.left + rect.width / 2 };
pendingSelection.value = { start: payload.start, end: payload.end };
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
assist.scopeMode.value = 'section';
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
floatingAssist.value.show = false;
nextTick(() => instructionRef.value?.focus());
}
function handleAssistAccept() {
const newBody = assist.accept();
body.value = newBody;
markDirty();
toast.show("Document updated");
}
// Tag suggestions
const suggestedTags = ref<string[]>([]);
const appliedTags = ref<Set<string>>(new Set());
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
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 = "";
let savedTags: string[] = [];
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
function markDirty() {
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId;
}
function onBodyUpdate(newVal: string) {
body.value = newVal;
markDirty();
}
onMounted(async () => {
if (noteId.value) {
await store.fetchNote(noteId.value);
if (store.currentNote) {
title.value = store.currentNote.title;
body.value = store.currentNote.body;
tags.value = [...(store.currentNote.tags || [])];
projectId.value = store.currentNote.project_id ?? null;
milestoneId.value = store.currentNote.milestone_id ?? null;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
}
// Restore pending draft if any
try {
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
if (draft) assist.loadDraft(draft);
} catch {
// No draft — normal
}
// Initial link suggestions
fetchLinkSuggestions();
}
});
async function save() {
if (saving.value) return;
saving.value = true;
try {
if (isEditing.value) {
await store.updateNote(noteId.value!, {
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
dirty.value = false;
toast.show("Note saved");
} else {
const note = await store.createNote({
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
});
dirty.value = false;
toast.show("Note created");
router.push(`/notes/${note.id}`);
}
} catch {
toast.show("Failed to save note", "error");
} finally {
saving.value = false;
}
}
const showDeleteConfirm = ref(false);
function remove() {
if (noteId.value) showDeleteConfirm.value = true;
}
async function confirmDelete() {
showDeleteConfirm.value = false;
if (!noteId.value) return;
try {
await store.deleteNote(noteId.value);
dirty.value = false;
toast.show("Note deleted");
router.push("/notes");
} catch {
toast.show("Failed to delete note", "error");
}
}
// Auto-save every 5 minutes
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return;
saving.value = true;
try {
await store.updateNote(noteId.value!, {
title: title.value, body: body.value, tags: tags.value,
project_id: projectId.value, milestone_id: milestoneId.value,
} as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
// Silent
} finally {
saving.value = false;
}
}
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
onUnmounted(() => { if (autoSaveTimer !== null) clearInterval(autoSaveTimer); });
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
onBeforeRouteLeave(() => {
if (dirty.value) return confirm("You have unsaved changes. Leave anyway?");
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) e.preventDefault();
}
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>
<main class="editor-page note-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
</div>
<input
v-model="title"
type="text"
placeholder="Title"
class="title-input"
@input="markDirty"
/>
</div>
<!-- Two-column body: main | sidebar -->
<div class="note-body">
<!-- ── Main column ────────────────────────────────────────── -->
<div class="note-main">
<div class="body-tabs-row">
<div class="editor-tabs">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
</div>
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
</div>
<!-- Streaming preview -->
<template v-if="assist.state.value === 'streaming'">
<div class="stream-label">Generating...</div>
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal editor -->
<template v-else>
<div v-show="!showPreview" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
/>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
/>
</template>
<!-- Error from assist -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
</div>
<!-- ── Sidebar ──────────────────────────────────────────── -->
<aside class="note-sidebar">
<button class="sidebar-toggle" @click="sidebarOpen = !sidebarOpen">
Details {{ sidebarOpen ? '▴' : '▾' }}
</button>
<div :class="['sidebar-content', { 'sidebar-open': sidebarOpen }]">
<!-- Project / Milestone / Tags -->
<div class="sb-field">
<label class="sb-label">Project</label>
<ProjectSelector
v-model="projectId"
@update:modelValue="milestoneId = null; markDirty()"
/>
</div>
<div v-if="projectId" class="sb-field">
<label class="sb-label">Milestone</label>
<MilestoneSelector
:projectId="projectId"
v-model="milestoneId"
@update:modelValue="markDirty"
/>
</div>
<div class="sb-field">
<label class="sb-label">Tags</label>
<TagInput
v-model="tags"
:fetchTags="(q: string) => store.fetchAllTags(q)"
@update:modelValue="markDirty"
/>
</div>
<div class="tag-suggest-row">
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
</button>
<template v-if="suggestedTags.length > 0">
<button
v-for="tag in suggestedTags"
:key="tag"
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
:disabled="appliedTags.has(tag)"
@click="applyTagSuggestion(tag)"
>
#{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button>
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">&times;</button>
</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) -->
<div class="assist-section">
<div class="assist-section-title">✨ Writing Assistant</div>
<!-- Scope selector -->
<div class="sb-field">
<label class="sb-label">Scope</label>
<select v-model="scopeSelectValue" class="sb-select" :disabled="assist.state.value === 'streaming'">
<option
v-for="opt in scopeOptions"
:key="opt.value"
:value="opt.value"
>{{ opt.label }}</option>
</select>
</div>
<!-- Idle: instruction + buttons -->
<template v-if="assist.state.value === 'idle'">
<textarea
ref="instructionRef"
v-model="assist.instruction.value"
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
class="btn-generate"
@click="assist.submit()"
:disabled="!assist.canSubmit.value"
>Generate</button>
<button
class="btn-proofread"
@click="assist.proofread()"
>Proofread</button>
</div>
</template>
<!-- Streaming: cancel -->
<template v-else-if="assist.state.value === 'streaming'">
<div class="assist-active-hint">Generating… see main area</div>
<button class="btn-clear" @click="assist.clearSelection()">Cancel</button>
</template>
<!-- Review: accept / reject -->
<template v-else-if="assist.state.value === 'review'">
<div class="assist-active-hint">Review the diff in the main area</div>
<div class="assist-actions">
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
<button class="btn-reject" @click="assist.reject()">Reject</button>
</div>
</template>
</div>
</div><!-- /sidebar-content -->
</aside>
</div><!-- /note-body -->
<!-- Floating inline assist button -->
<teleport to="body">
<button
v-if="floatingAssist.show"
class="inline-assist-btn"
:style="{ top: floatingAssist.top + 'px', left: floatingAssist.left + 'px' }"
@mousedown.prevent="handleInlineAssist"
>✨ Assist</button>
</teleport>
<!-- History panel -->
<HistoryPanel
v-if="showHistory && noteId"
:note-id="noteId"
:current-body="body"
@restore="(b: string, t: string[]) => { body = b; tags = t; markDirty(); }"
@close="showHistory = false"
/>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
<div class="modal-card" @click.stop>
<h3 class="modal-title">Delete Note</h3>
<p class="modal-message">Are you sure you want to delete this note? This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</main>
</template>
<style src="@/assets/editor-shared.css" />
<style scoped>
/* ── Two-column note layout ──────────────────────────────────── */
.note-editor-page {
max-width: 1600px;
}
.note-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
position: relative;
}
/* Main column */
.note-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 0.75rem 1.25rem 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.body-tabs-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.body-editor-wrap {
min-height: 200px;
}
.stream-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.stream-preview {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
padding: 0.75rem;
background: var(--color-bg-card);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
/* Right sidebar */
.note-sidebar {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--color-border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Mobile accordion toggle (hidden on desktop) */
.sidebar-toggle {
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
/* Sidebar field layout */
.sb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sb-select {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
}
.sb-select:focus { outline: none; border-color: var(--color-primary); }
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
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;
flex-direction: column;
gap: 0.5rem;
}
.assist-section-title {
font-size: 0.78rem;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* History button */
.btn-history {
padding: 0.45rem 1rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-family: inherit;
}
.btn-history:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
.note-main { padding: 0.75rem 1rem 1rem; overflow-y: visible; }
.note-sidebar {
width: 100%;
border-left: none;
border-top: 1px solid var(--color-border);
overflow-y: visible;
}
.sidebar-toggle { display: block; }
.sidebar-content {
display: none;
padding: 0.75rem 1rem;
}
.sidebar-content.sidebar-open { display: flex; }
}
</style>