CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m20s
CI & Build / Build & push image (push) Successful in 40s
Rule 27: a history nobody can read is not shipped. `RuleHistoryPanel.vue` sits below the fields in `RuleEditorSlideOver`, where a rule is read in full — not on the list row, where a history entry point would compete with the row's job. REUSE, DECIDED FIELD BY FIELD RATHER THAN ALL AT ONCE. DiffView.vue is reused unchanged: it takes DiffLine[] and nothing note-shaped. HistoryPanel.vue is NOT, and its props are the reason — noteId + currentBody, a NoteVersion carrying tags and pin columns, a fetch of /api/notes/…, a restore emit, pin/unpin buttons. Rules have no tags, no pins, and deliberately no restore, and a rule's text is EIGHT fields rather than one body, which changes the reader's question from "what changed" to "which fields moved". Recorded here rather than forked silently, per #3207. THE FORK THAT WAS ALREADY THERE. The LCS walk existed three times — privately in useAssist.ts, and again inside HistoryPanel.vue and VersionHistorySection.vue — character-identical apart from quote style, because computeDiff was never exported. Rather than add a fourth copy, it moves to utils/diff.ts and the three become imports; the extraction was verified equivalent to all three before anything was deleted. DiffLine is re-exported from useAssist so its existing importers are untouched. WHAT A ROW SHOWS: when, and which fields moved. A version holds the text the edit REPLACED, so the edit is the step from a row to the next NEWER state — the row above it, or, for the newest row, the rule as it stands now. Comparing against the row below would attribute every change to the wrong edit. A field nobody has fetched yet reads as neither changed nor unchanged. An edit that touched verify_with is badged "check reset", because that edit silently cleared verified_at (milestone 312) and put the rule back at the top of the staleness sweep — a moment visible nowhere else. The badge is a 12% color-mix TINT, not solid `--fs-warning`. `--fs-warning-fg` is defined in theme.css as "warning TEXT on a warning tint", so painting it over the solid token is exactly the same-hue contrast failure #3141 records. Every var() the component references resolves against theme.css, checked before pushing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
325 lines
9.2 KiB
TypeScript
325 lines
9.2 KiB
TypeScript
import { ref, computed, watch, type Ref } from "vue";
|
|
import { computeDiff, type DiffLine } from "@/utils/diff";
|
|
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";
|
|
|
|
// Re-exported: this composable was where DiffLine lived before the diff
|
|
// moved to a shared util, and every consumer still imports the type from here.
|
|
export type { DiffLine };
|
|
|
|
export interface AssistTarget {
|
|
text: string;
|
|
startOffset: number;
|
|
endOffset: number;
|
|
}
|
|
|
|
export interface NoteDraft {
|
|
id: number;
|
|
note_id: number;
|
|
proposed_body: string;
|
|
original_body: string;
|
|
instruction: string;
|
|
scope: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
|
|
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,
|
|
};
|
|
}
|