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>
This commit is contained in:
@@ -53,7 +53,7 @@ function computeDiff(a: string, b: string): DiffLine[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
|
||||
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
|
||||
const toast = useToastStore();
|
||||
|
||||
const state = ref<AssistState>("idle");
|
||||
@@ -166,6 +166,17 @@ export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -185,71 +196,64 @@ export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
|
||||
target_section: targetSection,
|
||||
instruction: instruction.value,
|
||||
whole_doc: wholeDoc,
|
||||
...(noteId?.value ? { note_id: noteId.value } : {}),
|
||||
...(projectId?.value ? { project_id: projectId.value } : {}),
|
||||
});
|
||||
|
||||
streamHandle = apiSSEStream("/api/notes/assist/stream", async (evt) => {
|
||||
if (evt.event === "chunk") {
|
||||
streamingText.value += evt.data.chunk as string;
|
||||
}
|
||||
if (evt.event === "done") {
|
||||
const fullText = (evt.data.full_text as string) || streamingText.value;
|
||||
proposedText.value = fullText;
|
||||
// Stream with automatic reconnection on dropped connections
|
||||
const MAX_RETRIES = 3;
|
||||
let lastEventId = -1;
|
||||
let gotDone = false;
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
state.value = "review";
|
||||
streamHandle = null;
|
||||
await _saveDraft();
|
||||
}
|
||||
if (evt.event === "error") {
|
||||
error.value = evt.data.error as string;
|
||||
toast.show("Assist failed: " + error.value, "error");
|
||||
state.value = "idle";
|
||||
streamHandle = null;
|
||||
}
|
||||
});
|
||||
|
||||
await streamHandle.done;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
proposedFullBody.value = _buildProposedFullBody(streamingText.value, wholeDoc);
|
||||
state.value = "review";
|
||||
await _saveDraft();
|
||||
} else {
|
||||
state.value = "idle";
|
||||
}
|
||||
streamHandle = null;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Request failed";
|
||||
|
||||
Reference in New Issue
Block a user