Files
FabledScribe/frontend/src/composables/useAssist.ts
T
bvandeusen 667ccd70cd Security audit, bug fixes, auto-save, and writing assistant improvements
Backend security & correctness:
- Add rate_limit.py: sliding-window rate limiter (asyncio) applied to login,
  register, forgot/reset password endpoints (10/60s or 5/300s per IP)
- app.py: add security headers in after_request (X-Frame-Options, CSP,
  X-Content-Type-Options, Referrer-Policy) using setdefault to preserve SSE headers
- auth.py: refactor duplicate login_required/admin_required into shared _check_auth()
- config.py: add TRUST_PROXY_HEADERS for proxy-aware client IP resolution
- routes/auth.py: rate limiting, _client_ip() helper, cleaned-up reset_password route
- routes/chat.py, notes.py, tasks.py: int() DoS fix on last_event_id; limit capped
  at 500; date.fromisoformat() wrapped in try/except → 400 on invalid dates
- services/auth.py: fix Setting.user_id update bug (filter on NULL not user.id);
  reset_password_with_token returns int|None (user_id) instead of bool
- services/backup.py: add _security_notice to full backup JSON export
- services/assist.py: system prompt explicitly preserves markdown list structure
  and nested indented sub-items

Infrastructure:
- docker-compose.yml: add healthcheck on app service (/api/health, 10s interval)
- .dockerignore: prevent secrets/node_modules/__pycache__/.env.* leaking into build

Frontend bug fixes:
- TaskCard.vue, TaskViewerView.vue: fix isOverdue() timezone bug (ISO string compare)
- useAssist.ts: accept() now resets state to idle when document changed since proposal
- stores/chat.ts: fix memory leak in _pollUntilLoaded() (try/catch around fetchStatus)
- TiptapEditor.vue: selection offset uses closest-match strategy (not first-match)
- utils/markdown.ts: explicit DOMPurify config with FORCE_BODY; remove as const
  (DOMPurify expects mutable string[])

New features:
- Auto-save (5-minute interval) in NoteEditorView and TaskEditorView — only when
  editing an existing dirty record; silent on error, shows "Auto-saved" toast
- sectionParser.ts: top-level bullet/numbered list items are now individual sections
  in the AI Assist panel (previously treated as one undifferentiated block)
- editor-shared.css: extracted ~500 lines of CSS duplicated between both editors;
  includes .inline-assist-btn at global scope (required for teleported elements)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 20:08:44 -05:00

270 lines
7.4 KiB
TypeScript

import { ref, computed, watch, type Ref } from "vue";
import { apiPost, apiSSEStream, type SSEStreamHandle } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import {
parseMarkdownSections,
type MarkdownSection,
} from "@/utils/sectionParser";
export type AssistState = "idle" | "streaming" | "review";
export interface AssistTarget {
text: string;
startOffset: number;
endOffset: number;
}
export interface DiffLine {
type: 'equal' | 'delete' | 'insert';
text: 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>) {
const chatStore = useChatStore();
const state = ref<AssistState>("idle");
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("");
const error = ref("");
const isProofreading = ref(false);
// Snapshot of body at the time a section/selection was chosen
let bodySnapshot = "";
let streamHandle: SSEStreamHandle | null = null;
const target = computed<AssistTarget | 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(
() =>
target.value !== null &&
instruction.value.trim().length > 0 &&
chatStore.chatReady &&
state.value !== "streaming"
);
const diff = computed<DiffLine[]>(() => {
if (state.value !== 'review' || !target.value || !proposedText.value) return [];
return computeDiff(target.value.text, proposedText.value);
});
function refreshSections() {
sections.value = parseMarkdownSections(body.value);
}
function selectSection(section: MarkdownSection) {
customSelection.value = null;
selectedSection.value = section;
bodySnapshot = body.value;
error.value = "";
}
function selectTextRange(start: number, end: number) {
if (start === end) return;
selectedSection.value = null;
customSelection.value = {
start,
end,
text: body.value.slice(start, end),
};
bodySnapshot = body.value;
error.value = "";
}
function clearSelection() {
if (streamHandle) {
streamHandle.close();
streamHandle = null;
}
selectedSection.value = null;
customSelection.value = null;
instruction.value = "";
streamingText.value = "";
proposedText.value = "";
error.value = "";
state.value = "idle";
}
async function submit() {
if (!canSubmit.value || !target.value) return;
state.value = "streaming";
streamingText.value = "";
proposedText.value = "";
error.value = "";
try {
// Step 1: Launch background generation
await apiPost("/api/notes/assist", {
body: body.value,
target_section: target.value.text,
instruction: instruction.value,
});
// Step 2: Tail the SSE buffer
streamHandle = apiSSEStream("/api/notes/assist/stream", (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;
state.value = "review";
streamHandle = null;
}
if (evt.event === "error") {
error.value = evt.data.error as string;
state.value = "idle";
streamHandle = null;
}
});
await streamHandle.done;
// Fallback: if stream ended without done/error, use accumulated text
if (state.value === "streaming") {
if (streamingText.value) {
proposedText.value = streamingText.value;
state.value = "review";
} else {
state.value = "idle";
}
streamHandle = null;
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Request failed";
state.value = "idle";
streamHandle = null;
}
}
async function proofread() {
if (state.value === 'streaming') return;
isProofreading.value = true;
selectedSection.value = null;
customSelection.value = { start: 0, end: body.value.length, text: body.value };
bodySnapshot = body.value;
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 {
if (!target.value || !proposedText.value) return 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.";
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 = "";
error.value = "";
state.value = "idle";
isProofreading.value = false;
return newBody;
}
function reject() {
proposedText.value = "";
streamingText.value = "";
error.value = "";
state.value = "idle";
isProofreading.value = false;
// Keep selection + instruction for retry
}
// Keep sections in sync with body automatically
watch(body, () => {
refreshSections();
}, { immediate: true });
return {
state,
sections,
selectedSection,
customSelection,
target,
instruction,
streamingText,
proposedText,
error,
canSubmit,
diff,
isProofreading,
refreshSections,
selectSection,
selectTextRange,
clearSelection,
submit,
proofread,
accept,
reject,
};
}