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:
2026-03-06 14:02:54 -05:00
parent 9036dfd931
commit 48f070f773
22 changed files with 767 additions and 113 deletions
@@ -45,7 +45,7 @@ _TOOL_LABELS: dict[str, str] = {
"get_note": "Reading note",
"list_notes": "Listing notes",
"list_tasks": "Searching tasks",
"search_notes": "Searching notes",
"search_notes": "Searching notes (semantic)",
"create_event": "Creating calendar event",
"list_events": "Searching calendar",
"search_events": "Searching calendar",
@@ -149,6 +149,7 @@ async def run_generation(
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
think: bool = False,
rag_project_id: int | None = None,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
MAX_TOOL_ROUNDS = 5
@@ -172,13 +173,14 @@ async def run_generation(
history_to_use, history_summary = await summarize_history_for_context(history, model)
# Phase 3: Build context and wait for model in parallel.
model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=90.0))
model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=180.0))
context_task = asyncio.create_task(build_context(
user_id, history_to_use, context_note_id, user_content,
history_summary=history_summary,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
rag_project_id=rag_project_id,
))
messages, context_meta = await context_task
@@ -192,7 +194,7 @@ async def run_generation(
buf.append_event("status", {"status": "Loading model..."})
loaded = await model_load_task
if not loaded:
logger.warning("Model %s did not load within 90s — proceeding anyway", model)
logger.warning("Model %s did not load within 180s — proceeding anyway", model)
t_start = time.monotonic()
timing: dict = {
@@ -415,6 +417,9 @@ async def run_assist_generation(
On each retry the accumulated content is reset so the done event
always reflects only the successful generation.
"""
input_chars = sum(len(m.get("content", "")) for m in messages)
logger.info("Assist generation started: model=%s, input_chars=%d", model, input_chars)
last_exc: BaseException | None = None
for attempt in range(3):
if attempt > 0:
@@ -429,9 +434,15 @@ async def run_assist_generation(
buf.content_so_far += chunk
buf.append_event("chunk", {"chunk": chunk})
output_chars = len(buf.content_so_far)
logger.info(
"Assist generation complete: output_chars=%d, events=%d",
output_chars, len(buf.events),
)
buf.state = GenerationState.COMPLETED
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "full_text": buf.content_so_far})
logger.info("Assist done event appended (event index %d)", len(buf.events) - 1)
return
except httpx.HTTPStatusError as exc: