Files
FabledGrimoire/frontend/src/composables/useAssist.ts
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

346 lines
10 KiB
TypeScript

import { ref, computed, watch, type Ref } from "vue";
import { apiPost, apiPut, apiDelete, apiSSEStream, type SSEStreamHandle } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import {
parseMarkdownSections,
type MarkdownSection,
} from "@/utils/sectionParser";
export type AssistState = "idle" | "streaming" | "review";
export type ScopeMode = "document" | "section";
export interface AssistTarget {
text: string;
startOffset: number;
endOffset: number;
}
export interface DiffLine {
type: 'equal' | 'delete' | 'insert';
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');
const m = aLines.length, n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i+1][j+1] + 1
: Math.max(dp[i+1][j], dp[i][j+1]);
const result: DiffLine[] = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
else result.push({ type: 'insert', text: bLines[j++] });
}
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
return result;
}
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: 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 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,
startOffset: customSelection.value.start,
endOffset: customSelection.value.end,
};
}
if (selectedSection.value) {
return {
text: selectedSection.value.content,
startOffset: selectedSection.value.startOffset,
endOffset: selectedSection.value.endOffset,
};
}
return null;
});
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' || !proposedFullBody.value) return [];
return computeDiff(bodySnapshot, proposedFullBody.value);
});
function refreshSections() {
sections.value = parseMarkdownSections(body.value);
}
function selectSection(section: MarkdownSection) {
scopeMode.value = 'section';
customSelection.value = null;
selectedSection.value = section;
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),
};
error.value = "";
}
function clearSelection() {
if (streamHandle) {
streamHandle.close();
streamHandle = null;
}
selectedSection.value = null;
customSelection.value = null;
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
}
}
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;
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 {
await apiPost("/api/notes/assist", {
body: body.value,
target_section: targetSection,
instruction: instruction.value,
whole_doc: wholeDoc,
...(noteId?.value ? { note_id: noteId.value } : {}),
...(projectId?.value ? { project_id: projectId.value } : {}),
});
// Stream with automatic reconnection on dropped connections
const MAX_RETRIES = 3;
let lastEventId = -1;
let gotDone = false;
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;
}
},
);
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;
proposedFullBody.value = _buildProposedFullBody(streamingText.value, wholeDoc);
state.value = "review";
await _saveDraft();
} else {
state.value = "idle";
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Request failed";
toast.show("Assist failed: " + error.value, "error");
state.value = "idle";
streamHandle = null;
}
}
async function proofread() {
if (state.value === 'streaming') return;
isProofreading.value = true;
scopeMode.value = 'document';
selectedSection.value = null;
customSelection.value = null;
error.value = '';
instruction.value =
"Proofread for clarity, grammar, spelling, and flow. " +
"Preserve the author's voice and all factual content. " +
"Return only the improved text without explanation.";
await submit();
}
function accept(): string {
const result = proposedFullBody.value || body.value;
selectedSection.value = null;
customSelection.value = null;
streamingText.value = "";
proposedText.value = "";
proposedFullBody.value = "";
error.value = "";
state.value = "idle";
isProofreading.value = false;
_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
}
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,
target,
instruction,
streamingText,
proposedText,
proposedFullBody,
error,
canSubmit,
diff,
isProofreading,
refreshSections,
selectSection,
selectTextRange,
clearSelection,
submit,
proofread,
accept,
reject,
loadDraft,
};
}