Note editor sidebar, full-doc assist, persistent drafts, version history
NoteEditorView: two-column sidebar layout (project/milestone/tags/assist always visible), removed assist toggle button, InlineAssistPanel removed. Writing assist: whole_doc mode rewrites entire document; DiffView.vue replaces editor during review showing full-document diff. Scope dropdown in sidebar switches between whole-document and section modes. Persistent drafts: migration 0022 adds note_drafts (UNIQUE per note+user) and note_versions (max 20, auto-pruned) tables. Draft saved after generation completes, restored on editor mount, cleared on accept/reject. Version snapshot created automatically whenever note body changes on save. HistoryPanel.vue: version list + DiffView modal, restore button writes body back to editor. Config: OLLAMA_NUM_CTX default raised to 65536; assist num_predict now tracks Config.OLLAMA_NUM_CTX instead of a hardcoded 4096. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { ref, computed, watch, type Ref } from "vue";
|
||||
import { apiPost, apiSSEStream, type SSEStreamHandle } from "@/api/client";
|
||||
import { apiPost, apiPut, apiDelete, apiSSEStream, type SSEStreamHandle } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import {
|
||||
parseMarkdownSections,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/utils/sectionParser";
|
||||
|
||||
export type AssistState = "idle" | "streaming" | "review";
|
||||
export type ScopeMode = "document" | "section";
|
||||
|
||||
export interface AssistTarget {
|
||||
text: string;
|
||||
@@ -19,6 +20,17 @@ export interface DiffLine {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface NoteDraft {
|
||||
id: number;
|
||||
note_id: number;
|
||||
proposed_body: string;
|
||||
original_body: string;
|
||||
instruction: string;
|
||||
scope: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function computeDiff(a: string, b: string): DiffLine[] {
|
||||
const aLines = a.split('\n');
|
||||
const bLines = b.split('\n');
|
||||
@@ -41,24 +53,28 @@ function computeDiff(a: string, b: string): DiffLine[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAssist(body: Ref<string>) {
|
||||
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
|
||||
const toast = useToastStore();
|
||||
|
||||
const state = ref<AssistState>("idle");
|
||||
const scopeMode = ref<ScopeMode>("document");
|
||||
const sections = ref<MarkdownSection[]>([]);
|
||||
const selectedSection = ref<MarkdownSection | null>(null);
|
||||
const customSelection = ref<{ start: number; end: number; text: string } | null>(null);
|
||||
const instruction = ref("");
|
||||
const streamingText = ref("");
|
||||
const proposedText = ref("");
|
||||
// Full proposed document body (for section mode: body with section replaced)
|
||||
const proposedFullBody = ref("");
|
||||
const error = ref("");
|
||||
const isProofreading = ref(false);
|
||||
|
||||
// Snapshot of body at the time a section/selection was chosen
|
||||
// Snapshot of body at the time generation was started
|
||||
let bodySnapshot = "";
|
||||
let streamHandle: SSEStreamHandle | null = null;
|
||||
|
||||
const target = computed<AssistTarget | null>(() => {
|
||||
if (scopeMode.value === 'document') return null;
|
||||
if (customSelection.value) {
|
||||
return {
|
||||
text: customSelection.value.text,
|
||||
@@ -76,16 +92,16 @@ export function useAssist(body: Ref<string>) {
|
||||
return null;
|
||||
});
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
target.value !== null &&
|
||||
instruction.value.trim().length > 0 &&
|
||||
state.value !== "streaming"
|
||||
const canSubmit = computed(() =>
|
||||
instruction.value.trim().length > 0 &&
|
||||
state.value !== "streaming" &&
|
||||
(scopeMode.value === 'document' || target.value !== null)
|
||||
);
|
||||
|
||||
// Diff always compares full document: original snapshot → proposed
|
||||
const diff = computed<DiffLine[]>(() => {
|
||||
if (state.value !== 'review' || !target.value || !proposedText.value) return [];
|
||||
return computeDiff(target.value.text, proposedText.value);
|
||||
if (state.value !== 'review' || !proposedFullBody.value) return [];
|
||||
return computeDiff(bodySnapshot, proposedFullBody.value);
|
||||
});
|
||||
|
||||
function refreshSections() {
|
||||
@@ -93,21 +109,21 @@ export function useAssist(body: Ref<string>) {
|
||||
}
|
||||
|
||||
function selectSection(section: MarkdownSection) {
|
||||
scopeMode.value = 'section';
|
||||
customSelection.value = null;
|
||||
selectedSection.value = section;
|
||||
bodySnapshot = body.value;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function selectTextRange(start: number, end: number) {
|
||||
if (start === end) return;
|
||||
scopeMode.value = 'section';
|
||||
selectedSection.value = null;
|
||||
customSelection.value = {
|
||||
start,
|
||||
end,
|
||||
text: body.value.slice(start, end),
|
||||
};
|
||||
bodySnapshot = body.value;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
@@ -121,35 +137,84 @@ export function useAssist(body: Ref<string>) {
|
||||
instruction.value = "";
|
||||
streamingText.value = "";
|
||||
proposedText.value = "";
|
||||
proposedFullBody.value = "";
|
||||
error.value = "";
|
||||
state.value = "idle";
|
||||
isProofreading.value = false;
|
||||
}
|
||||
|
||||
async function _saveDraft() {
|
||||
if (!noteId?.value) return;
|
||||
try {
|
||||
await apiPut(`/api/notes/${noteId.value}/draft`, {
|
||||
proposed_body: proposedFullBody.value,
|
||||
original_body: bodySnapshot,
|
||||
instruction: instruction.value,
|
||||
scope: scopeMode.value,
|
||||
});
|
||||
} catch {
|
||||
// Non-critical — ignore draft save errors
|
||||
}
|
||||
}
|
||||
|
||||
async function _deleteDraft() {
|
||||
if (!noteId?.value) return;
|
||||
try {
|
||||
await apiDelete(`/api/notes/${noteId.value}/draft`);
|
||||
} catch {
|
||||
// Ignore — draft may not exist
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || !target.value) return;
|
||||
if (!canSubmit.value) return;
|
||||
|
||||
bodySnapshot = body.value;
|
||||
state.value = "streaming";
|
||||
streamingText.value = "";
|
||||
proposedText.value = "";
|
||||
proposedFullBody.value = "";
|
||||
error.value = "";
|
||||
|
||||
const wholeDoc = scopeMode.value === 'document';
|
||||
const targetSection = wholeDoc ? body.value : (target.value?.text ?? "");
|
||||
|
||||
try {
|
||||
// Step 1: Launch background generation
|
||||
await apiPost("/api/notes/assist", {
|
||||
body: body.value,
|
||||
target_section: target.value.text,
|
||||
target_section: targetSection,
|
||||
instruction: instruction.value,
|
||||
whole_doc: wholeDoc,
|
||||
});
|
||||
|
||||
// Step 2: Tail the SSE buffer
|
||||
streamHandle = apiSSEStream("/api/notes/assist/stream", (evt) => {
|
||||
streamHandle = apiSSEStream("/api/notes/assist/stream", async (evt) => {
|
||||
if (evt.event === "chunk") {
|
||||
streamingText.value += evt.data.chunk as string;
|
||||
}
|
||||
if (evt.event === "done") {
|
||||
proposedText.value = (evt.data.full_text as string) || streamingText.value;
|
||||
const fullText = (evt.data.full_text as string) || streamingText.value;
|
||||
proposedText.value = fullText;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
state.value = "review";
|
||||
streamHandle = null;
|
||||
await _saveDraft();
|
||||
}
|
||||
if (evt.event === "error") {
|
||||
error.value = evt.data.error as string;
|
||||
@@ -161,11 +226,26 @@ export function useAssist(body: Ref<string>) {
|
||||
|
||||
await streamHandle.done;
|
||||
|
||||
// Fallback: if stream ended without done/error, use accumulated text
|
||||
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;
|
||||
}
|
||||
}
|
||||
state.value = "review";
|
||||
await _saveDraft();
|
||||
} else {
|
||||
state.value = "idle";
|
||||
}
|
||||
@@ -182,9 +262,9 @@ export function useAssist(body: Ref<string>) {
|
||||
async function proofread() {
|
||||
if (state.value === 'streaming') return;
|
||||
isProofreading.value = true;
|
||||
scopeMode.value = 'document';
|
||||
selectedSection.value = null;
|
||||
customSelection.value = { start: 0, end: body.value.length, text: body.value };
|
||||
bodySnapshot = body.value;
|
||||
customSelection.value = null;
|
||||
error.value = '';
|
||||
instruction.value =
|
||||
"Proofread for clarity, grammar, spelling, and flow. " +
|
||||
@@ -194,60 +274,48 @@ export function useAssist(body: Ref<string>) {
|
||||
}
|
||||
|
||||
function accept(): string {
|
||||
if (!target.value || !proposedText.value) return body.value;
|
||||
const result = proposedFullBody.value || body.value;
|
||||
|
||||
const t = target.value;
|
||||
|
||||
// Validate that the body hasn't changed at the target offsets
|
||||
if (body.value !== bodySnapshot) {
|
||||
const currentSlice = body.value.slice(t.startOffset, t.endOffset);
|
||||
if (currentSlice !== t.text) {
|
||||
error.value = "The document changed since this suggestion was made. Please clear and try again.";
|
||||
toast.show(error.value, "error");
|
||||
state.value = "idle";
|
||||
return body.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proposed text ends with a newline for clean separation
|
||||
let text = proposedText.value;
|
||||
if (!text.endsWith("\n")) {
|
||||
text += "\n";
|
||||
}
|
||||
|
||||
const newBody =
|
||||
body.value.slice(0, t.startOffset) +
|
||||
text +
|
||||
body.value.slice(t.endOffset);
|
||||
|
||||
// Reset state
|
||||
selectedSection.value = null;
|
||||
customSelection.value = null;
|
||||
streamingText.value = "";
|
||||
proposedText.value = "";
|
||||
proposedFullBody.value = "";
|
||||
error.value = "";
|
||||
state.value = "idle";
|
||||
isProofreading.value = false;
|
||||
|
||||
return newBody;
|
||||
_deleteDraft();
|
||||
return result;
|
||||
}
|
||||
|
||||
function reject() {
|
||||
proposedText.value = "";
|
||||
proposedFullBody.value = "";
|
||||
streamingText.value = "";
|
||||
error.value = "";
|
||||
state.value = "idle";
|
||||
isProofreading.value = false;
|
||||
_deleteDraft();
|
||||
// Keep selection + instruction for retry
|
||||
}
|
||||
|
||||
// Keep sections in sync with body automatically
|
||||
function loadDraft(draft: NoteDraft) {
|
||||
bodySnapshot = draft.original_body;
|
||||
proposedText.value = draft.proposed_body;
|
||||
proposedFullBody.value = draft.proposed_body;
|
||||
instruction.value = draft.instruction;
|
||||
scopeMode.value = draft.scope as ScopeMode;
|
||||
state.value = "review";
|
||||
}
|
||||
|
||||
watch(body, () => {
|
||||
refreshSections();
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
state,
|
||||
scopeMode,
|
||||
sections,
|
||||
selectedSection,
|
||||
customSelection,
|
||||
@@ -255,6 +323,7 @@ export function useAssist(body: Ref<string>) {
|
||||
instruction,
|
||||
streamingText,
|
||||
proposedText,
|
||||
proposedFullBody,
|
||||
error,
|
||||
canSubmit,
|
||||
diff,
|
||||
@@ -267,5 +336,6 @@ export function useAssist(body: Ref<string>) {
|
||||
proofread,
|
||||
accept,
|
||||
reject,
|
||||
loadDraft,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user