Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/server/src/modules/canvas/canvas-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,49 @@ async function collect(
}

describe('searchCanvas — tiered emission', () => {
it.each([
['needle', 2, 6],
['i\u0307', 0, 1],
] as const)(
'preserves original UTF-16 source addresses after case folding: %s',
async (query, offset, length) => {
const events = await collect(
[mkNode('n1', 'note')],
[mkContent('n1', 'note', { content: '\u0130 needle' })],
{ query, fields: ['content'] },
);
expect(events.find((event) => event.type === 'match')).toMatchObject({
match: { source: { offset, length } },
});
},
);
it('numbers raw Markdown occurrences including a link URL before the next visible paragraph', async () => {
const source = '[needle](https://example.test/needle)\n\nParagraph needle';
const events = await collect(
[mkNode('n1', 'note')],
[
mkContent('n1', 'note', {
content: source,
}),
],
{ query: 'needle', fields: ['content'] },
);
const ordinals = events.flatMap((event) =>
event.type === 'match' ? [event.match.occurrenceIndex] : [],
);
expect(ordinals).toEqual([0, 1, 2]);
const addresses = events.flatMap((event) =>
event.type === 'match' ? [event.match.source] : [],
);
expect(addresses.map((address) => address?.offset)).toEqual([
source.indexOf('needle'),
source.indexOf('needle', 10),
source.lastIndexOf('needle'),
]);
expect(new Set(addresses.map((address) => address?.hash)).size).toBe(1);
expect(addresses[0]?.hash).toMatch(/^[a-f0-9]{64}$/);
});

it('emits metadata-tier matches before content-tier matches', async () => {
const nodes = [mkNode('n1', 'note'), mkNode('n2', 'text')];
const contents = [
Expand Down Expand Up @@ -648,6 +691,11 @@ describe('searchCanvas — conversation tier', () => {
{ query, fields: ['conversation'] },
);
expect(events.filter((event) => event.type === 'match')).toHaveLength(1);
expect(events.find((event) => event.type === 'match')).toMatchObject({
match: {
source: { hash: expect.stringMatching(/^[a-f0-9]{64}$/), offset: 0 },
},
});
const contextEvents = await collect(
[mkNode('quoted-question', 'question', 'quoted-thread')],
[mkContent('quoted-question', 'question', { label: 'Q' })],
Expand Down
114 changes: 93 additions & 21 deletions apps/server/src/modules/canvas/canvas-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@
* scan testable without spinning up Fastify.
*/

import { createHash } from 'node:crypto';

import {
META_SEARCH_FIELDS,
chatInputText,
literalTextMatches,
type CanvasSearchEvent,
type CanvasSearchMatch,
type CanvasSearchRequest,
type CanvasSearchSource,
type SearchField,
} from '@huabu/shared';

Expand Down Expand Up @@ -205,6 +209,7 @@ function scanNodeContent(
nodeType: node.type,
label: content?.label ?? null,
tryEmit,
sourceAt: sourceLocator(body),
});
}
}
Expand All @@ -221,16 +226,77 @@ function scanNodeContent(
* user message and an unrelated reply, while `buildSnippet`'s
* whitespace collapse still renders them on one row.
*/
function sourceLocator(
text: string,
): (offset: number, length?: number) => CanvasSearchSource {
const hash = createHash('sha256').update(text).digest('hex');
return (offset, length) => ({ hash, offset, length });
}

function buildThreadHaystack(
turns: readonly AgentTurn[],
recoverInternalToolNames: boolean,
): string {
): { text: string; sourceAt: FieldEmitContext['sourceAt'] } {
const segments: string[] = [];
const sources: {
start: number;
end: number;
locate: (offset: number, length?: number) => CanvasSearchSource;
}[] = [];
let length = 0;
const append = (text: string, searchable = true): number => {
const start = length + (segments.length ? 2 : 0);
segments.push(text);
length = start + text.length;
if (searchable)
sources.push({ start, end: length, locate: sourceLocator(text) });
return start;
};
for (const turn of turns) {
const user = chatEnvelopeFromSubmission(turn.request)?.user;
const userText = user ? chatInputText(user) : undefined;
if (typeof userText === 'string' && userText.length > 0) {
segments.push(userText);
const start = append(userText, false);
// Keep the existing searchable serialization while addressing only
// literal pieces the UI actually renders (not quote-prefix syntax).
let cursor = 0;
for (const comment of user?.quotedComments ?? []) {
const locate = sourceLocator(comment.quote);
let quoteOffset = 0;
for (const line of comment.quote.split(/\r?\n/)) {
const lineStart = userText.indexOf(`> ${line}`, cursor);
if (lineStart < 0) break;
const offset = quoteOffset;
sources.push({
start: start + lineStart + 2,
end: start + lineStart + 2 + line.length,
locate: (position, matchLength) =>
locate(offset + position, matchLength),
});
cursor = lineStart + line.length + 2;
quoteOffset +=
line.length +
(comment.quote.slice(quoteOffset + line.length).startsWith('\r\n')
? 2
: 1);
}
const commentStart = userText.indexOf(comment.comment, cursor);
if (comment.comment && commentStart >= 0) {
sources.push({
start: start + commentStart,
end: start + commentStart + comment.comment.length,
locate: sourceLocator(comment.comment),
});
cursor = commentStart + comment.comment.length;
}
}
if (user?.text && userText.endsWith(user.text)) {
sources.push({
start: start + userText.length - user.text.length,
end: start + userText.length,
locate: sourceLocator(user.text),
});
}
}
let interpretationIndexed = false;
for (const msg of turn.transcript) {
Expand All @@ -245,19 +311,26 @@ function buildThreadHaystack(
)
interpretationIndexed = true;
if (interpretation?.state === 'reported') {
segments.push(interpretation.text);
if (interpretation.explanation)
segments.push(interpretation.explanation);
append(interpretation.text);
if (interpretation.explanation) append(interpretation.explanation);
}
}
// Only assistant prose contributes model speech; `tool_call`,
// `thinking`, `plan`, and `error` fragments are excluded.
if (msg.type !== 'text') continue;
const text = msg.data.content;
if (typeof text === 'string' && text.length > 0) segments.push(text);
if (typeof text === 'string' && text.length > 0) append(text);
}
}
return segments.join('\n\n');
return {
text: segments.join('\n\n'),
sourceAt: (offset, matchLength) => {
const source = sources.find(
(entry) => offset >= entry.start && offset + matchLength <= entry.end,
);
return source?.locate(offset - source.start, matchLength);
},
};
}

/**
Expand All @@ -283,12 +356,13 @@ async function scanNodeConversation(
(await agenetes.record(namespace, node.threadId))?.spec.kind ===
INTERNAL_DRIVER_KIND;
const haystack = buildThreadHaystack(turns, recoverInternalToolNames);
if (haystack.length === 0) return;
emitFieldHits('conversation', haystack, needleLower, needleLen, {
if (haystack.text.length === 0) return;
emitFieldHits('conversation', haystack.text, needleLower, needleLen, {
nodeId: node.id,
nodeType: node.type,
label,
tryEmit,
sourceAt: haystack.sourceAt,
});
}

Expand All @@ -308,15 +382,15 @@ async function scanNodeConversation(
function scanOccurrences(
haystack: string,
needleLower: string,
needleLen: number,
_needleLen: number,
tryEmit: (match: CanvasSearchMatch) => boolean,
buildMatch: (
snippet: { text: string; matchStart: number; matchLength: number },
occurrenceIndex: number,
sourceOffset: number,
sourceLength: number,
) => CanvasSearchMatch,
): void {
const lower = haystack.toLowerCase();
let from = 0;
// 0-based ordinal of the hit *within this scan*. The wire contract
// documents `occurrenceIndex` as monotonically increasing per
// (entity, field), and every caller invokes `scanOccurrences` once
Expand All @@ -325,14 +399,10 @@ function scanOccurrences(
// already shipped are 0..k-1 and map cleanly to the n-th DOM
// occurrence on the client.
let occurrenceIndex = 0;
while (true) {
const idx = lower.indexOf(needleLower, from);
if (idx === -1) return;
const snippet = buildSnippet(haystack, idx, needleLen);
if (!tryEmit(buildMatch(snippet, occurrenceIndex))) return;
// `max(1, needleLen)` guards against a zero-width needle locking
// the loop — schema-bounded to >= 1 char today, but cheap insurance.
from = idx + Math.max(1, needleLen);
for (const { start, end } of literalTextMatches(haystack, needleLower)) {
const snippet = buildSnippet(haystack, start, end - start);
if (!tryEmit(buildMatch(snippet, occurrenceIndex, start, end - start)))
return;
occurrenceIndex += 1;
}
}
Expand Down Expand Up @@ -380,6 +450,7 @@ interface FieldEmitContext {
label: string | null;
/** Returns false when the global limit is exhausted. */
tryEmit: (match: CanvasSearchMatch) => boolean;
sourceAt?: (offset: number, length: number) => CanvasSearchSource | undefined;
}

/**
Expand All @@ -406,7 +477,7 @@ function emitFieldHits(
needleLower,
needleLen,
ctx.tryEmit,
(snippet, occurrenceIndex) => ({
(snippet, occurrenceIndex, sourceOffset, sourceLength) => ({
nodeId: ctx.nodeId,
nodeType: ctx.nodeType,
label: ctx.label,
Expand All @@ -419,6 +490,7 @@ function emitFieldHits(
// whitespace runs (e.g. needle `"a b"`, snippet `"a b"`).
matchLength: snippet.matchLength,
occurrenceIndex,
source: ctx.sourceAt?.(sourceOffset, sourceLength),
}),
);
}
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/modules/remote_fs/rfs.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,19 @@ describe('POST /api/rfs/:canvasId/query', () => {
result: {
count: 1,
truncated: false,
matches: [{ tier: 'content', match: { nodeId: 'node-1' } }],
matches: [
{
tier: 'content',
match: {
nodeId: 'node-1',
source: {
hash: expect.stringMatching(/^[a-f0-9]{64}$/),
offset: 6,
length: 10,
},
},
},
],
},
});
} finally {
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@milkdown/prose": "^7.21.1",
"@milkdown/react": "^7.21.1",
"@milkdown/utils": "^7.21.1",
"@noble/hashes": "^1.8.0",
"@xyflow/react": "^12.10.0",
"axios": "^1.13.4",
"clsx": "^2.1.1",
Expand Down
82 changes: 82 additions & 0 deletions apps/web/src/components/CodeMirror/RawMarkdownEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { EditorView } from '@codemirror/view';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';

import { hashSearchSource } from '@/hooks/useSearchSource';
import { usePreviewSearchStore } from '@/store/previewSearchStore';

import RawMarkdownEditor from './RawMarkdownEditor';
import { InPreviewSearchBar } from '../Panels/ExpandedNodePanel/InPreviewSearchBar';
import { PreviewSearchAdapterProvider } from '../Panels/ExpandedNodePanel/PreviewSearchAdapterContext';

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

let host: HTMLDivElement;
let root: Root;

beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(
new DOMRect(10, 100, 80, 20),
);
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
usePreviewSearchStore.getState().close();
});

afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it.each([false, true])(
'reveals exact raw-source URL text without editing or focusing (%s)',
async (readOnly) => {
const value = '[needle](https://example.test/needle)\n\nParagraph needle';
const onChange = vi.fn();
const external = document.createElement('input');
document.body.append(external);
external.focus();
usePreviewSearchStore.getState().reveal('raw-note', 'needle', 1, {
hash: await hashSearchSource(value),
offset: value.indexOf('needle', value.indexOf('https')),
length: 6,
});
act(() =>
root.render(
<PreviewSearchAdapterProvider>
<InPreviewSearchBar scopeEl={host} nodeId="raw-note" />
<RawMarkdownEditor
searchNodeId="raw-note"
value={value}
onChange={onChange}
readOnly={readOnly}
/>
</PreviewSearchAdapterProvider>,
),
);
await act(async () => {
await vi.waitFor(() =>
expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(),
);
});
expect(host.textContent).toContain('2/3');
const content = host.querySelector<HTMLElement>('.cm-content');
if (!content) throw new Error('Missing CodeMirror document');
const view = EditorView.findFromDOM(content);
expect(view?.state.doc.toString()).toBe(value);
expect(view?.state.selection.main.head).toBe(0);
expect(onChange).not.toHaveBeenCalled();
expect(document.activeElement).toBe(external);
external.remove();
},
);
Loading
Loading