From 147c69005179f117adfe4491a13d36294d925e8d Mon Sep 17 00:00:00 2001 From: DhruvTilva Date: Fri, 26 Jun 2026 00:04:41 +0530 Subject: [PATCH 1/4] fix: prevent spurious leading spaces in nested codeBlock interior lines When a codeBlock is nested inside an indented structure like a bulletListItem, the \_render_block\ function prepends the block's indentation \prefix\ to every line. However, for codeBlock elements, only the fence markers (the opening and closing \\\) should carry the block indentation. Interior code lines must not have the prefix prepended, because markdown parsers treat leading spaces inside a code fence as part of the code content. This fix removes the prefix from interior code lines to prevent code snippets stored in notes from gaining spurious whitespace. --- surfsense_backend/app/utils/blocknote_to_markdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_backend/app/utils/blocknote_to_markdown.py b/surfsense_backend/app/utils/blocknote_to_markdown.py index 3731b4b3c..8c172c7bd 100644 --- a/surfsense_backend/app/utils/blocknote_to_markdown.py +++ b/surfsense_backend/app/utils/blocknote_to_markdown.py @@ -133,7 +133,7 @@ def _render_block( code_text = _render_inline_content(content) if content else "" lines.append(f"{prefix}```{language}") for code_line in code_text.split("\n"): - lines.append(f"{prefix}{code_line}") + lines.append(code_line) lines.append(f"{prefix}```") elif block_type == "table": From bf805fd81ef651df1a8d288d311b2d633c05a016 Mon Sep 17 00:00:00 2001 From: DhruvTilva Date: Fri, 26 Jun 2026 00:09:11 +0530 Subject: [PATCH 2/4] fix: use token-safe truncation for document embeddings The document saving logic used a hardcoded character slice ([:4000]) for the document summary content fed to the embedder. For UTF-8 documents (e.g., Arabic, Chinese, Japanese), characters above U+007F take multiple bytes but count as 1 character in a slice, potentially producing text that exceeds the token limit of the embedding model. Replaced the arbitrary slice with runcate_for_embedding(), which safely bounds the text using the embedding model's actual tokenizer. --- surfsense_backend/app/tasks/document_processors/_save.py | 5 ++++- .../app/tasks/document_processors/markdown_processor.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/tasks/document_processors/_save.py b/surfsense_backend/app/tasks/document_processors/_save.py index 3b9616cbd..9456e2f1c 100644 --- a/surfsense_backend/app/tasks/document_processors/_save.py +++ b/surfsense_backend/app/tasks/document_processors/_save.py @@ -10,6 +10,7 @@ from app.utils.document_converters import ( create_document_chunks, embed_text, generate_content_hash, + truncate_for_embedding, ) from ._helpers import ( @@ -73,7 +74,9 @@ async def save_file_document( if should_skip: return doc - document_content = f"File: {file_name}\n\n{markdown_content[:4000]}" + document_content = ( + f"File: {file_name}\n\n{truncate_for_embedding(markdown_content)}" + ) document_embedding = embed_text(document_content) chunks = await create_document_chunks(markdown_content) doc_metadata = {"FILE_NAME": file_name, "ETL_SERVICE": etl_service} diff --git a/surfsense_backend/app/tasks/document_processors/markdown_processor.py b/surfsense_backend/app/tasks/document_processors/markdown_processor.py index 19a4df87d..463951a64 100644 --- a/surfsense_backend/app/tasks/document_processors/markdown_processor.py +++ b/surfsense_backend/app/tasks/document_processors/markdown_processor.py @@ -13,6 +13,7 @@ from app.utils.document_converters import ( create_document_chunks, embed_text, generate_content_hash, + truncate_for_embedding, ) from ._helpers import ( @@ -182,7 +183,9 @@ async def add_received_markdown_file_document( return doc # Content changed - continue to update - summary_content = f"File: {file_name}\n\n{file_in_markdown[:4000]}" + summary_content = ( + f"File: {file_name}\n\n{truncate_for_embedding(file_in_markdown)}" + ) summary_embedding = embed_text(summary_content) # Process chunks From 24ff4b7d937ed6ccf32c17ee654b1106cb90908c Mon Sep 17 00:00:00 2001 From: yagitoshiro Date: Sun, 28 Jun 2026 13:13:46 +0900 Subject: [PATCH 3/4] fix(web): don't submit chat on Enter during IME composition The chat composers submit on Enter without checking whether an IME composition is active. For Japanese/Chinese/Korean input, pressing Enter to confirm a conversion fires the submit handler and sends a half-typed message. Guard the Enter handlers with e.nativeEvent.isComposing in the main chat editor, the anonymous free composer, and the mention/prompt picker key navigation. --- .../components/assistant-ui/inline-mention-editor.tsx | 4 +++- surfsense_web/components/assistant-ui/thread.tsx | 4 ++++ surfsense_web/components/free-chat/free-composer.tsx | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 5fc942e54..ee2d1c873 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -696,7 +696,9 @@ export const InlineMentionEditor = forwardRef { // Arrow / Enter / Escape navigation for the active picker. const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + // While an IME composition is active (e.g. confirming a Japanese/Chinese/ + // Korean conversion), let the Enter/Arrow keys reach the IME instead of + // driving picker navigation/selection. + if (e.nativeEvent.isComposing) return; if (showPromptPicker) { if (e.key === "ArrowDown") { e.preventDefault(); diff --git a/surfsense_web/components/free-chat/free-composer.tsx b/surfsense_web/components/free-chat/free-composer.tsx index 162b906ad..d4523a4f9 100644 --- a/surfsense_web/components/free-chat/free-composer.tsx +++ b/surfsense_web/components/free-chat/free-composer.tsx @@ -99,7 +99,9 @@ export const FreeComposer: FC = () => { gate("mention documents"); return; } - if (e.key === "Enter" && !e.shiftKey) { + // Ignore Enter while an IME composition is active (e.g. confirming a + // Japanese/Chinese/Korean conversion) so it doesn't submit the message. + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim()) { aui.composer().send(); From 9b2f880e3cace3e58008f099495427954c835fc7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:49:30 -0700 Subject: [PATCH 4/4] refactor: delete unused notesApiService orphan API module --- surfsense_web/lib/apis/notes-api.service.ts | 147 -------------------- 1 file changed, 147 deletions(-) delete mode 100644 surfsense_web/lib/apis/notes-api.service.ts diff --git a/surfsense_web/lib/apis/notes-api.service.ts b/surfsense_web/lib/apis/notes-api.service.ts deleted file mode 100644 index eac3d96ed..000000000 --- a/surfsense_web/lib/apis/notes-api.service.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { z } from "zod"; -import { ValidationError } from "../error"; -import { baseApiService } from "./base-api.service"; - -// Request/Response schemas -const createNoteRequest = z.object({ - search_space_id: z.number(), - title: z.string().min(1), - source_markdown: z.string().optional(), -}); - -const createNoteResponse = z.object({ - id: z.number(), - title: z.string(), - document_type: z.string(), - content: z.string(), - content_hash: z.string(), - unique_identifier_hash: z.string().nullable(), - document_metadata: z.record(z.string(), z.any()).nullable(), - search_space_id: z.number(), - created_at: z.string(), - updated_at: z.string().nullable(), -}); - -const getNotesRequest = z.object({ - search_space_id: z.number(), - skip: z.number().optional(), - page: z.number().optional(), - page_size: z.number().optional(), -}); - -const noteItem = z.object({ - id: z.number(), - title: z.string(), - document_type: z.string(), - content: z.string(), - content_hash: z.string(), - unique_identifier_hash: z.string().nullable(), - document_metadata: z.record(z.string(), z.any()).nullable(), - search_space_id: z.number(), - created_at: z.string(), - updated_at: z.string().nullable(), -}); - -const getNotesResponse = z.object({ - items: z.array(noteItem), - total: z.number(), - page: z.number(), - page_size: z.number(), - has_more: z.boolean(), -}); - -const deleteNoteRequest = z.object({ - search_space_id: z.number(), - note_id: z.number(), -}); - -const deleteNoteResponse = z.object({ - message: z.string(), - note_id: z.number(), -}); - -// Type exports -export type CreateNoteRequest = z.infer; -export type CreateNoteResponse = z.infer; -export type GetNotesRequest = z.infer; -export type GetNotesResponse = z.infer; -export type NoteItem = z.infer; -export type DeleteNoteRequest = z.infer; -export type DeleteNoteResponse = z.infer; - -class NotesApiService { - /** - * Create a new note - */ - createNote = async (request: CreateNoteRequest) => { - const parsedRequest = createNoteRequest.safeParse(request); - - if (!parsedRequest.success) { - console.error("Invalid request:", parsedRequest.error); - const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } - - const { search_space_id, title, source_markdown } = parsedRequest.data; - - // Send both title and source_markdown in request body - const body = { - title, - ...(source_markdown !== undefined && { source_markdown }), - }; - - return baseApiService.post( - `/api/v1/search-spaces/${search_space_id}/notes`, - createNoteResponse, - { body } - ); - }; - - /** - * Get list of notes - */ - getNotes = async (request: GetNotesRequest) => { - const parsedRequest = getNotesRequest.safeParse(request); - - if (!parsedRequest.success) { - console.error("Invalid request:", parsedRequest.error); - const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } - - const { search_space_id, skip, page, page_size } = parsedRequest.data; - - // Build query params - const params = new URLSearchParams(); - if (skip !== undefined) params.append("skip", String(skip)); - if (page !== undefined) params.append("page", String(page)); - if (page_size !== undefined) params.append("page_size", String(page_size)); - - return baseApiService.get( - `/api/v1/search-spaces/${search_space_id}/notes?${params.toString()}`, - getNotesResponse - ); - }; - - /** - * Delete a note - */ - deleteNote = async (request: DeleteNoteRequest) => { - const parsedRequest = deleteNoteRequest.safeParse(request); - - if (!parsedRequest.success) { - console.error("Invalid request:", parsedRequest.error); - const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } - - const { search_space_id, note_id } = parsedRequest.data; - - return baseApiService.delete( - `/api/v1/search-spaces/${search_space_id}/notes/${note_id}`, - deleteNoteResponse - ); - }; -} - -export const notesApiService = new NotesApiService();