langflower 0.0.10 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -70
- package/docs/public/README.md +2 -2
- package/docs/public/configuration.md +4 -0
- package/docs/public/extending.md +18 -2
- package/docs/public/getting-started.md +7 -0
- package/docs/public/how-it-works.md +18 -11
- package/docs/public/product.md +35 -25
- package/package.json +1 -1
- package/ui-dist/chunk-QI4IYILJ.js +4 -0
- package/ui-dist/{chunk-7UMVYN3A.js → chunk-UG3GFV4Y.js} +1 -1
- package/ui-dist/index.html +2 -2
- package/ui-dist/main-ODIDCCNX.js +315 -0
- package/ui-dist/styles-2JMBAQC3.css +1 -0
- package/vendor/common-nodes/dist/ai/features/llm-session/llm-session-shell.d.ts +2 -2
- package/vendor/common-nodes/dist/ai/features/llm-session/llm-session-shell.js +39 -7
- package/vendor/common-nodes/dist/ai/features/llm-session/run-session-machine.d.ts +11 -0
- package/vendor/common-nodes/dist/ai/features/llm-session/run-session-machine.js +2 -2
- package/vendor/common-nodes/dist/ai/features/run-host-services.d.ts +10 -0
- package/vendor/common-nodes/dist/ai/nodes/critique/node.js +5 -1
- package/vendor/common-nodes/dist/ai/nodes/fake-llm/node.js +7 -1
- package/vendor/common-nodes/dist/ai/nodes/openai-llm/node.js +5 -1
- package/vendor/common-nodes/dist/ai/nodes/review/node.js +7 -1
- package/vendor/common-nodes/dist/ai/nodes/sub-agent/node.js +62 -22
- package/vendor/common-nodes/dist/catalog.js +10 -0
- package/vendor/common-nodes/dist/crawl/crawl/node.js +11 -3
- package/vendor/common-nodes/dist/crawl/crawl-tools/node.js +5 -1
- package/vendor/common-nodes/dist/crawl/extract-links/node.js +7 -1
- package/vendor/common-nodes/dist/crawl/fetch-url/node.js +13 -4
- package/vendor/common-nodes/dist/crawl/save-page/node.js +11 -3
- package/vendor/common-nodes/dist/embeddings/create-embedding.d.ts +39 -0
- package/vendor/common-nodes/dist/embeddings/create-embedding.js +154 -0
- package/vendor/common-nodes/dist/embeddings/embed-provider/node.d.ts +53 -0
- package/vendor/common-nodes/dist/embeddings/embed-provider/node.js +126 -0
- package/vendor/common-nodes/dist/embeddings/embed-similarity/node.d.ts +20 -0
- package/vendor/common-nodes/dist/embeddings/embed-similarity/node.js +81 -0
- package/vendor/common-nodes/dist/embeddings/embed-text/node.d.ts +53 -0
- package/vendor/common-nodes/dist/embeddings/embed-text/node.js +91 -0
- package/vendor/common-nodes/dist/embeddings/from-embedding.d.ts +8 -0
- package/vendor/common-nodes/dist/embeddings/from-embedding.js +24 -0
- package/vendor/common-nodes/dist/embeddings/resolve-embedding-provider-model.d.ts +11 -0
- package/vendor/common-nodes/dist/embeddings/resolve-embedding-provider-model.js +8 -0
- package/vendor/common-nodes/dist/flow/checkpoint/node.js +7 -3
- package/vendor/common-nodes/dist/flow/delay/node.d.ts +2 -6
- package/vendor/common-nodes/dist/flow/delay/node.js +13 -9
- package/vendor/common-nodes/dist/flow/loop/node.js +7 -1
- package/vendor/common-nodes/dist/flow/merge/node.js +7 -1
- package/vendor/common-nodes/dist/flow/repeat/node.js +5 -1
- package/vendor/common-nodes/dist/flow/router/node.js +7 -1
- package/vendor/common-nodes/dist/hitl/chat-input/node.d.ts +2 -2
- package/vendor/common-nodes/dist/hitl/chat-input/node.js +8 -4
- package/vendor/common-nodes/dist/hitl/review-gate/node.js +11 -5
- package/vendor/common-nodes/dist/langflower-tools/node.js +5 -1
- package/vendor/common-nodes/dist/logic/assert/node.js +7 -1
- package/vendor/common-nodes/dist/logic/compare/node.js +7 -1
- package/vendor/common-nodes/dist/logic/gate/node.js +7 -1
- package/vendor/common-nodes/dist/logic/if/node.js +7 -1
- package/vendor/common-nodes/dist/logic/switch/node.js +7 -1
- package/vendor/common-nodes/dist/mcp/mcp-http/node.js +55 -53
- package/vendor/common-nodes/dist/mcp/mcp-stdio/node.js +52 -50
- package/vendor/common-nodes/dist/memory/memory-tools/node.js +5 -1
- package/vendor/common-nodes/dist/output/finish/node.js +13 -1
- package/vendor/common-nodes/dist/output/preview/node.js +9 -2
- package/vendor/common-nodes/dist/primitives/boolean/node.js +7 -1
- package/vendor/common-nodes/dist/primitives/number/node.js +7 -1
- package/vendor/common-nodes/dist/primitives/string/node.js +7 -1
- package/vendor/common-nodes/dist/primitives/string-multiline/node.d.ts +17 -0
- package/vendor/common-nodes/dist/primitives/string-multiline/node.js +31 -0
- package/vendor/common-nodes/dist/text/append-file/node.js +11 -3
- package/vendor/common-nodes/dist/text/concat/node.js +5 -1
- package/vendor/common-nodes/dist/text/read-file/node.js +9 -3
- package/vendor/common-nodes/dist/text/split-paced/node.d.ts +21 -0
- package/vendor/common-nodes/dist/text/split-paced/node.js +93 -0
- package/vendor/common-nodes/dist/text/write-file/node.js +11 -3
- package/vendor/common-nodes/dist/tools/tool-collection/node.js +5 -2
- package/vendor/common-nodes/package.json +4 -0
- package/vendor/node-sdk/dist/node-factory/define-embed/embed-handle.d.ts +25 -0
- package/vendor/node-sdk/dist/node-factory/define-embed/embed-handle.js +25 -0
- package/vendor/node-sdk/dist/node-factory/define-mcp/mcp-handle.d.ts +1 -0
- package/vendor/node-sdk/dist/node-factory/define-mcp/mcp-handle.js +1 -0
- package/vendor/node-sdk/dist/node-factory/define-node/define-node.d.ts +2 -0
- package/vendor/node-sdk/dist/node-factory/define-node/define-node.js +4 -1
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/define-reactive-node.d.ts +3 -1
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/define-reactive-node.js +2 -1
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/io-helpers.d.ts +15 -0
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/io-helpers.js +17 -1
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/port-meta.d.ts +17 -4
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/test/samples/delay-node.js +4 -2
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/types.d.ts +4 -3
- package/vendor/node-sdk/dist/testing/create-node-harness.d.ts +30 -0
- package/vendor/node-sdk/dist/testing/create-node-harness.js +68 -0
- package/vendor/node-sdk/package.json +4 -4
- package/vendor/runtime/dist/types.d.ts +1 -1
- package/vendor/server/dist/bootstrap/project-bootstrap.service.d.ts +1 -1
- package/vendor/server/dist/bootstrap/seed-skeleton.d.ts +2 -2
- package/vendor/server/dist/bootstrap/seed-skeleton.js +17 -2
- package/vendor/server/dist/bridge/bind-embed-context.d.ts +7 -0
- package/vendor/server/dist/bridge/bind-embed-context.js +16 -0
- package/vendor/server/dist/bridge/build-execution-context.d.ts +2 -2
- package/vendor/server/dist/bridge/build-execution-context.js +6 -2
- package/vendor/server/dist/bridge/emit-bootstrap.js +4 -1
- package/vendor/server/dist/bridge/settings-draft-controller.js +7 -3
- package/vendor/server/dist/bridge/wire-config-handlers.js +2 -0
- package/vendor/server/dist/bridge/wire-editor-handlers.js +14 -0
- package/vendor/server/dist/config/langflower-config.service.d.ts +2 -0
- package/vendor/server/dist/config/langflower-config.service.js +30 -0
- package/vendor/server/dist/session/build-session-bootstrap.js +1 -0
- package/vendor/server/dist/session/langflower-session.d.ts +3 -1
- package/vendor/server/dist/session/langflower-session.js +3 -1
- package/vendor/server/dist/workflow/apply-editor-mutation.js +6 -0
- package/vendor/server/dist/workflow/workflow-persisted-inputs.js +8 -6
- package/vendor/server/skeleton/instructions.md +15 -3
- package/vendor/server/skeleton/nodes/hello-embed/README.md +185 -0
- package/vendor/server/skeleton/nodes/hello-embed/hello-embed.nodes.test.ts +181 -0
- package/vendor/server/skeleton/nodes/hello-embed/ingest.ts +126 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/chunk-markdown.test.ts +40 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/chunk-markdown.ts +123 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/ingest-search.test.ts +180 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/ingest.ts +128 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/paths.test.ts +20 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/paths.ts +70 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/schema.ts +21 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/search.ts +254 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/text-embedder.ts +14 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/vectors.ts +126 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/walk-markdown.test.ts +62 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/walk-markdown.ts +67 -0
- package/vendor/server/skeleton/nodes/hello-embed/package.json +11 -0
- package/vendor/server/skeleton/nodes/hello-embed/search-handle.ts +119 -0
- package/vendor/server/skeleton/nodes/hello-embed/search.ts +98 -0
- package/vendor/server/skeleton/nodes/hello-embed/tsconfig.json +14 -0
- package/vendor/server/skeleton/nodes/my-nodes/README.md +13 -3
- package/vendor/server/skeleton/schemas/langflower-config.schema.json +4 -0
- package/vendor/server/skeleton/skills/langflower-helper/SKILL.md +99 -26
- package/vendor/server/skeleton/skills/langflower-helper/architecture.md +11 -6
- package/vendor/server/skeleton/skills/langflower-helper/layout.md +24 -18
- package/vendor/server/skeleton/skills/langflower-node-writer/SKILL.md +8 -1
- package/vendor/server/skeleton/workflows/kb-ingest.json +88 -0
- package/vendor/server/skeleton/workflows/kb-manual-search.json +105 -0
- package/vendor/server/skeleton/workflows/kb-rag.json +192 -0
- package/vendor/server/skeleton/workflows/kb-tool.json +111 -0
- package/vendor/shared/dist/langflower-bus-config.d.ts +22 -6
- package/vendor/shared/dist/langflower-bus-config.js +23 -7
- package/vendor/shared/dist/langflower-config/merge-langflower-config-layers.test.js +8 -0
- package/vendor/shared/dist/langflower-config/settings-draft.d.ts +5 -0
- package/vendor/shared/dist/langflower-config/settings-draft.js +15 -7
- package/vendor/shared/dist/langflower.d.ts +1 -1
- package/vendor/shared/dist/langflower.js +1 -1
- package/vendor/shared/dist/types/langflower-bootstrap.d.ts +6 -0
- package/vendor/shared/dist/types/langflower-config.d.ts +17 -0
- package/vendor/shared/dist/types/langflower-palette.d.ts +2 -1
- package/vendor/tools/dist/harness-types.d.ts +1 -1
- package/vendor/tools/dist/mcp/build-mcp-handle.d.ts +3 -21
- package/vendor/tools/dist/mcp/build-mcp-handle.js +2 -11
- package/vendor/tools/dist/mcp/create-system-mcp-handles.d.ts +9 -6
- package/vendor/tools/dist/mcp/create-system-mcp-handles.js +21 -17
- package/vendor/tools/package.json +1 -0
- package/ui-dist/chunk-MDGXX73Z.js +0 -4
- package/ui-dist/main-Z7CPFXFA.js +0 -313
- package/ui-dist/styles-2TMDAUD7.css +0 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { runIngest } from './ingest.ts';
|
|
6
|
+
import { DEFAULT_SQLITE_PATH } from './paths.ts';
|
|
7
|
+
import { runSearch } from './search.ts';
|
|
8
|
+
import type { TextEmbedder } from './text-embedder.ts';
|
|
9
|
+
import { l2Normalize } from './vectors.ts';
|
|
10
|
+
|
|
11
|
+
const fakeEmbedder = (): TextEmbedder => ({
|
|
12
|
+
expectedDim: 8,
|
|
13
|
+
embedTexts: async (texts) =>
|
|
14
|
+
texts.map((text) => {
|
|
15
|
+
const values = new Array<number>(8).fill(0);
|
|
16
|
+
if (text.toLowerCase().includes('alpha')) {
|
|
17
|
+
values[0] = 1;
|
|
18
|
+
} else if (text.toLowerCase().includes('beta')) {
|
|
19
|
+
values[1] = 1;
|
|
20
|
+
} else {
|
|
21
|
+
values[7] = 1;
|
|
22
|
+
}
|
|
23
|
+
return l2Normalize(values);
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const collect = async <T>(iter: AsyncIterable<T>): Promise<readonly T[]> => {
|
|
28
|
+
const events: T[] = [];
|
|
29
|
+
for await (const event of iter) {
|
|
30
|
+
events.push(event);
|
|
31
|
+
}
|
|
32
|
+
return events;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const progressTexts = (
|
|
36
|
+
events: readonly { readonly kind: string; readonly text?: string }[],
|
|
37
|
+
): readonly string[] =>
|
|
38
|
+
events.flatMap((event) =>
|
|
39
|
+
event.kind === 'progress' && event.text !== undefined
|
|
40
|
+
? [event.text]
|
|
41
|
+
: [],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
describe('ingest + search libs', () => {
|
|
45
|
+
let root: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'hello-embed-lib-'));
|
|
49
|
+
await fs.writeFile(
|
|
50
|
+
path.join(root, 'notes.md'),
|
|
51
|
+
'# Alpha\nalpha body\n\n# Beta\nbeta body\n',
|
|
52
|
+
'utf8',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('indexes heading chunks and ranks the matching query first', async () => {
|
|
61
|
+
const sqlitePath = path.join(root, DEFAULT_SQLITE_PATH);
|
|
62
|
+
const events = await collect(
|
|
63
|
+
runIngest({
|
|
64
|
+
sqlitePath,
|
|
65
|
+
sourceDir: root,
|
|
66
|
+
embedder: fakeEmbedder(),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
expect(events.at(-1)).toMatchObject({
|
|
70
|
+
kind: 'finish',
|
|
71
|
+
chunkCount: 2,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await runSearch({
|
|
75
|
+
sqlitePath,
|
|
76
|
+
query: 'alpha',
|
|
77
|
+
topK: 8,
|
|
78
|
+
embedder: fakeEmbedder(),
|
|
79
|
+
});
|
|
80
|
+
expect(result.hits[0]?.heading).toBe('Alpha');
|
|
81
|
+
expect(result.text).toContain('Question:\n');
|
|
82
|
+
expect(result.text).toContain('alpha');
|
|
83
|
+
expect(result.text).toContain('Context:');
|
|
84
|
+
expect(result.text).toContain('notes.md');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('prefixes progress with a zero-padded chunk counter', async () => {
|
|
88
|
+
const events = await collect(
|
|
89
|
+
runIngest({
|
|
90
|
+
sqlitePath: path.join(root, DEFAULT_SQLITE_PATH),
|
|
91
|
+
sourceDir: root,
|
|
92
|
+
embedder: fakeEmbedder(),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
expect(progressTexts(events)).toEqual([
|
|
96
|
+
'(1/2) notes.md — Alpha',
|
|
97
|
+
'(2/2) notes.md — Beta',
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('pads the progress counter to the digit width of the total', async () => {
|
|
102
|
+
const headings = Array.from(
|
|
103
|
+
{ length: 10 },
|
|
104
|
+
(_, index) => `# H${String(index)}\nbody\n`,
|
|
105
|
+
).join('\n');
|
|
106
|
+
await fs.writeFile(path.join(root, 'notes.md'), headings, 'utf8');
|
|
107
|
+
const events = await collect(
|
|
108
|
+
runIngest({
|
|
109
|
+
sqlitePath: path.join(root, DEFAULT_SQLITE_PATH),
|
|
110
|
+
sourceDir: root,
|
|
111
|
+
embedder: fakeEmbedder(),
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
const lines = progressTexts(events);
|
|
115
|
+
expect(lines[0]).toBe('(01/10) notes.md — H0');
|
|
116
|
+
expect(lines.at(-1)).toBe('(10/10) notes.md — H9');
|
|
117
|
+
expect(lines).toHaveLength(10);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('errors when the sqlite file is missing', async () => {
|
|
121
|
+
await expect(
|
|
122
|
+
runSearch({
|
|
123
|
+
sqlitePath: path.join(root, 'missing.sqlite'),
|
|
124
|
+
query: 'alpha',
|
|
125
|
+
topK: 8,
|
|
126
|
+
embedder: fakeEmbedder(),
|
|
127
|
+
}),
|
|
128
|
+
).rejects.toThrow(/Run kb-ingest first/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('packs full chunk bodies into Question/Context text', async () => {
|
|
132
|
+
const longBody = `alpha ${'x'.repeat(280)}`;
|
|
133
|
+
await fs.writeFile(
|
|
134
|
+
path.join(root, 'notes.md'),
|
|
135
|
+
`# Alpha\n${longBody}\n`,
|
|
136
|
+
'utf8',
|
|
137
|
+
);
|
|
138
|
+
const sqlitePath = path.join(root, DEFAULT_SQLITE_PATH);
|
|
139
|
+
await collect(
|
|
140
|
+
runIngest({
|
|
141
|
+
sqlitePath,
|
|
142
|
+
sourceDir: root,
|
|
143
|
+
embedder: fakeEmbedder(),
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
const result = await runSearch({
|
|
147
|
+
sqlitePath,
|
|
148
|
+
query: 'alpha',
|
|
149
|
+
topK: 8,
|
|
150
|
+
embedder: fakeEmbedder(),
|
|
151
|
+
});
|
|
152
|
+
expect(result.text).toContain('Question:\nalpha');
|
|
153
|
+
expect(result.text).toContain(longBody);
|
|
154
|
+
expect(result.text.includes(`${'x'.repeat(237)}…`)).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('ranks a keyword-only FTS hit above equal cosine dummies', async () => {
|
|
158
|
+
await fs.writeFile(
|
|
159
|
+
path.join(root, 'notes.md'),
|
|
160
|
+
'# Alpha\nalpha body\n\n# Beta\nbeta body xylophoneunique\n',
|
|
161
|
+
'utf8',
|
|
162
|
+
);
|
|
163
|
+
const sqlitePath = path.join(root, DEFAULT_SQLITE_PATH);
|
|
164
|
+
await collect(
|
|
165
|
+
runIngest({
|
|
166
|
+
sqlitePath,
|
|
167
|
+
sourceDir: root,
|
|
168
|
+
embedder: fakeEmbedder(),
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const result = await runSearch({
|
|
172
|
+
sqlitePath,
|
|
173
|
+
query: 'xylophoneunique',
|
|
174
|
+
topK: 8,
|
|
175
|
+
embedder: fakeEmbedder(),
|
|
176
|
+
});
|
|
177
|
+
expect(result.hits[0]?.heading).toBe('Beta');
|
|
178
|
+
expect(result.text).toContain('xylophoneunique');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { chunkMarkdown } from './chunk-markdown.ts';
|
|
5
|
+
import { SCHEMA_SQL } from './schema.ts';
|
|
6
|
+
import type { TextEmbedder } from './text-embedder.ts';
|
|
7
|
+
import { walkMarkdown } from './walk-markdown.ts';
|
|
8
|
+
import { clearVecCache, float32ToBlob, l2Normalize } from './vectors.ts';
|
|
9
|
+
|
|
10
|
+
export type IngestProgressEvent = {
|
|
11
|
+
readonly kind: 'progress';
|
|
12
|
+
readonly text: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type IngestFinishEvent = {
|
|
16
|
+
readonly kind: 'finish';
|
|
17
|
+
readonly chunkCount: number;
|
|
18
|
+
readonly fileCount: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type IngestEvent = IngestProgressEvent | IngestFinishEvent;
|
|
22
|
+
|
|
23
|
+
export type RunIngestOptions = {
|
|
24
|
+
readonly sqlitePath: string;
|
|
25
|
+
readonly sourceDir: string;
|
|
26
|
+
readonly embedder: TextEmbedder;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const dimMismatchMessage = (got: number, stored: number): string =>
|
|
30
|
+
`Embedding dim ${String(got)} does not match stored dim ${String(stored)}. Re-run ingest after switching models.`;
|
|
31
|
+
|
|
32
|
+
const padCount = (value: number, width: number): string =>
|
|
33
|
+
String(value).padStart(width, '0');
|
|
34
|
+
|
|
35
|
+
export const runIngest = async function* (
|
|
36
|
+
options: RunIngestOptions,
|
|
37
|
+
): AsyncGenerator<IngestEvent> {
|
|
38
|
+
const { sqlitePath, sourceDir, embedder } = options;
|
|
39
|
+
await fs.mkdir(path.dirname(sqlitePath), { recursive: true });
|
|
40
|
+
try {
|
|
41
|
+
await fs.unlink(sqlitePath);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const code =
|
|
44
|
+
error !== null && typeof error === 'object' && 'code' in error
|
|
45
|
+
? String((error as { code: unknown }).code)
|
|
46
|
+
: '';
|
|
47
|
+
if (code !== 'ENOENT') {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const db = new DatabaseSync(sqlitePath);
|
|
53
|
+
try {
|
|
54
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
55
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
56
|
+
db.exec(SCHEMA_SQL);
|
|
57
|
+
clearVecCache();
|
|
58
|
+
|
|
59
|
+
const files = await walkMarkdown(sourceDir);
|
|
60
|
+
const readable = files.filter((file) => file.skipped === undefined);
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
if (file.skipped !== undefined) {
|
|
63
|
+
yield { kind: 'progress', text: file.skipped };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (readable.length === 0) {
|
|
68
|
+
yield { kind: 'progress', text: 'no markdown files' };
|
|
69
|
+
yield { kind: 'finish', chunkCount: 0, fileCount: 0 };
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const chunks = readable.flatMap((file) =>
|
|
74
|
+
chunkMarkdown(file.relPath, file.text),
|
|
75
|
+
);
|
|
76
|
+
const insertChunk = db.prepare(
|
|
77
|
+
'INSERT INTO chunk (id, path, heading, text, embed_text) VALUES (?, ?, ?, ?, ?)',
|
|
78
|
+
);
|
|
79
|
+
const insertVec = db.prepare(
|
|
80
|
+
'INSERT INTO chunk_vec (id, dim, vector) VALUES (?, ?, ?)',
|
|
81
|
+
);
|
|
82
|
+
const insertFts = db.prepare(
|
|
83
|
+
'INSERT INTO chunk_fts (id, heading, text) VALUES (?, ?, ?)',
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
let expectedDim = embedder.expectedDim;
|
|
87
|
+
let embedded = 0;
|
|
88
|
+
const width = String(chunks.length).length;
|
|
89
|
+
for (const chunk of chunks) {
|
|
90
|
+
if (chunk.truncated !== undefined) {
|
|
91
|
+
yield { kind: 'progress', text: chunk.truncated };
|
|
92
|
+
}
|
|
93
|
+
const [raw] = await embedder.embedTexts([chunk.embedText]);
|
|
94
|
+
if (raw === undefined) {
|
|
95
|
+
throw new Error('Embedding returned no vector');
|
|
96
|
+
}
|
|
97
|
+
const vector = l2Normalize([...raw]);
|
|
98
|
+
if (expectedDim === undefined) {
|
|
99
|
+
expectedDim = vector.length;
|
|
100
|
+
} else if (vector.length !== expectedDim) {
|
|
101
|
+
throw new Error(dimMismatchMessage(vector.length, expectedDim));
|
|
102
|
+
}
|
|
103
|
+
insertChunk.run(
|
|
104
|
+
chunk.id,
|
|
105
|
+
chunk.path,
|
|
106
|
+
chunk.heading,
|
|
107
|
+
chunk.text,
|
|
108
|
+
chunk.embedText,
|
|
109
|
+
);
|
|
110
|
+
insertVec.run(chunk.id, expectedDim, float32ToBlob(vector));
|
|
111
|
+
insertFts.run(chunk.id, chunk.heading, chunk.text);
|
|
112
|
+
embedded += 1;
|
|
113
|
+
const label = chunk.heading.length > 0 ? chunk.heading : '(intro)';
|
|
114
|
+
yield {
|
|
115
|
+
kind: 'progress',
|
|
116
|
+
text: `(${padCount(embedded, width)}/${padCount(chunks.length, width)}) ${chunk.path} — ${label}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
yield {
|
|
121
|
+
kind: 'finish',
|
|
122
|
+
chunkCount: embedded,
|
|
123
|
+
fileCount: readable.length,
|
|
124
|
+
};
|
|
125
|
+
} finally {
|
|
126
|
+
db.close();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_SQLITE_PATH,
|
|
5
|
+
resolveSqlitePath,
|
|
6
|
+
resolveUnderProject,
|
|
7
|
+
} from './paths.ts';
|
|
8
|
+
|
|
9
|
+
describe('resolveUnderProject', () => {
|
|
10
|
+
it('rejects paths that escape the project', () => {
|
|
11
|
+
expect(() => resolveUnderProject('/proj', '../outside')).toThrow(
|
|
12
|
+
/escapes projectDir/,
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('resolves sqlite default under the project', () => {
|
|
17
|
+
const resolved = resolveSqlitePath('/proj', '');
|
|
18
|
+
expect(resolved).toBe(path.resolve('/proj', DEFAULT_SQLITE_PATH));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/** Project-relative index path. Same default on every pack node. */
|
|
4
|
+
export const DEFAULT_SQLITE_PATH = '.langflower/.cache/hello-embed/kb.sqlite';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SEARCH_TOP_K = 8;
|
|
7
|
+
|
|
8
|
+
export const MAX_SEARCH_TOP_K = 50;
|
|
9
|
+
|
|
10
|
+
export const MAX_FILE_BYTES = 512 * 1024;
|
|
11
|
+
|
|
12
|
+
export const MAX_CHUNKS_PER_FILE = 200;
|
|
13
|
+
|
|
14
|
+
export const SKIP_DIR_NAMES = new Set(['node_modules', '.git']);
|
|
15
|
+
|
|
16
|
+
export const CACHE_PREFIX = '.langflower/.cache';
|
|
17
|
+
|
|
18
|
+
const toPosix = (value: string): string => value.replaceAll('\\', '/');
|
|
19
|
+
|
|
20
|
+
export const asPosixRelative = (value: string): string => toPosix(value);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve `relativeOrEmpty` under `projectDir`. Empty means `projectDir`.
|
|
24
|
+
* Rejects paths that escape the project root.
|
|
25
|
+
*/
|
|
26
|
+
export const resolveUnderProject = (
|
|
27
|
+
projectDir: string,
|
|
28
|
+
relativeOrEmpty: string,
|
|
29
|
+
): string => {
|
|
30
|
+
const root = path.resolve(projectDir);
|
|
31
|
+
const trimmed = relativeOrEmpty.trim();
|
|
32
|
+
const resolved =
|
|
33
|
+
trimmed.length === 0
|
|
34
|
+
? root
|
|
35
|
+
: path.isAbsolute(trimmed)
|
|
36
|
+
? path.resolve(trimmed)
|
|
37
|
+
: path.resolve(root, trimmed);
|
|
38
|
+
const rel = path.relative(root, resolved);
|
|
39
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
40
|
+
throw new Error(`Path escapes projectDir: ${trimmed}`);
|
|
41
|
+
}
|
|
42
|
+
return resolved;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const resolveSqlitePath = (
|
|
46
|
+
projectDir: string,
|
|
47
|
+
override?: string,
|
|
48
|
+
): string => {
|
|
49
|
+
const raw =
|
|
50
|
+
typeof override === 'string' && override.trim().length > 0
|
|
51
|
+
? override.trim()
|
|
52
|
+
: DEFAULT_SQLITE_PATH;
|
|
53
|
+
return resolveUnderProject(projectDir, raw);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const clampTopK = (value: unknown, fallback: number): number => {
|
|
57
|
+
const parsed =
|
|
58
|
+
typeof value === 'number'
|
|
59
|
+
? value
|
|
60
|
+
: typeof value === 'string' && value.trim().length > 0
|
|
61
|
+
? Number(value)
|
|
62
|
+
: fallback;
|
|
63
|
+
if (!Number.isFinite(parsed)) {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
return Math.min(MAX_SEARCH_TOP_K, Math.max(1, Math.floor(parsed)));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const isCacheRelative = (relPosix: string): boolean =>
|
|
70
|
+
relPosix === CACHE_PREFIX || relPosix.startsWith(`${CACHE_PREFIX}/`);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const SCHEMA_SQL = `
|
|
2
|
+
CREATE TABLE chunk (
|
|
3
|
+
id TEXT PRIMARY KEY,
|
|
4
|
+
path TEXT NOT NULL,
|
|
5
|
+
heading TEXT NOT NULL,
|
|
6
|
+
text TEXT NOT NULL,
|
|
7
|
+
embed_text TEXT NOT NULL
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
CREATE TABLE chunk_vec (
|
|
11
|
+
id TEXT PRIMARY KEY REFERENCES chunk(id),
|
|
12
|
+
dim INTEGER NOT NULL,
|
|
13
|
+
vector BLOB NOT NULL
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE VIRTUAL TABLE chunk_fts USING fts5(
|
|
17
|
+
id UNINDEXED,
|
|
18
|
+
heading,
|
|
19
|
+
text
|
|
20
|
+
);
|
|
21
|
+
`.trim();
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import type { TextEmbedder } from './text-embedder.ts';
|
|
4
|
+
import { l2Normalize, loadVecIndex, topCosine } from './vectors.ts';
|
|
5
|
+
|
|
6
|
+
export type SearchHit = {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly heading: string;
|
|
9
|
+
readonly score: number;
|
|
10
|
+
readonly text: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RunSearchOptions = {
|
|
14
|
+
readonly sqlitePath: string;
|
|
15
|
+
readonly query: string;
|
|
16
|
+
readonly topK: number;
|
|
17
|
+
readonly embedder: TextEmbedder;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ChunkRow = {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly heading: string;
|
|
24
|
+
readonly text: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type RankedId = {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const RRF_K = 60;
|
|
32
|
+
|
|
33
|
+
const FTS_TOKEN = /[\p{L}\p{N}_]+/gu;
|
|
34
|
+
|
|
35
|
+
const asChunkRow = (row: unknown): ChunkRow => {
|
|
36
|
+
if (
|
|
37
|
+
row === null ||
|
|
38
|
+
typeof row !== 'object' ||
|
|
39
|
+
!('id' in row) ||
|
|
40
|
+
!('path' in row) ||
|
|
41
|
+
!('heading' in row) ||
|
|
42
|
+
!('text' in row)
|
|
43
|
+
) {
|
|
44
|
+
throw new Error('Unexpected chunk row');
|
|
45
|
+
}
|
|
46
|
+
const id = (row as { id: unknown }).id;
|
|
47
|
+
const path = (row as { path: unknown }).path;
|
|
48
|
+
const heading = (row as { heading: unknown }).heading;
|
|
49
|
+
const text = (row as { text: unknown }).text;
|
|
50
|
+
if (
|
|
51
|
+
typeof id !== 'string' ||
|
|
52
|
+
typeof path !== 'string' ||
|
|
53
|
+
typeof heading !== 'string' ||
|
|
54
|
+
typeof text !== 'string'
|
|
55
|
+
) {
|
|
56
|
+
throw new Error('chunk row has invalid types');
|
|
57
|
+
}
|
|
58
|
+
return { id, path, heading, text };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type VecRow = {
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly dim: number;
|
|
64
|
+
readonly vector: Uint8Array;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const asVecRow = (row: unknown): VecRow => {
|
|
68
|
+
if (
|
|
69
|
+
row === null ||
|
|
70
|
+
typeof row !== 'object' ||
|
|
71
|
+
!('id' in row) ||
|
|
72
|
+
!('dim' in row) ||
|
|
73
|
+
!('vector' in row)
|
|
74
|
+
) {
|
|
75
|
+
throw new Error('Unexpected chunk_vec row');
|
|
76
|
+
}
|
|
77
|
+
const id = (row as { id: unknown }).id;
|
|
78
|
+
const dimRaw = (row as { dim: unknown }).dim;
|
|
79
|
+
const vector = (row as { vector: unknown }).vector;
|
|
80
|
+
const dim = typeof dimRaw === 'bigint' ? Number(dimRaw) : dimRaw;
|
|
81
|
+
if (
|
|
82
|
+
typeof id !== 'string' ||
|
|
83
|
+
typeof dim !== 'number' ||
|
|
84
|
+
!(vector instanceof Uint8Array)
|
|
85
|
+
) {
|
|
86
|
+
throw new Error('chunk_vec row has invalid types');
|
|
87
|
+
}
|
|
88
|
+
return { id, dim, vector };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const retrieveCandidateLimit = (topK: number): number => Math.max(topK * 4, 32);
|
|
92
|
+
|
|
93
|
+
/** Quoted FTS5 tokens so chat text cannot inject MATCH operators. */
|
|
94
|
+
export const ftsMatchQuery = (raw: string): string | undefined => {
|
|
95
|
+
const tokens = raw.match(FTS_TOKEN);
|
|
96
|
+
if (tokens === null || tokens.length === 0) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return tokens.map((token) => `"${token.replaceAll('"', '')}"`).join(' ');
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const reciprocalRankFusion = (
|
|
103
|
+
lists: readonly (readonly RankedId[])[],
|
|
104
|
+
k: number,
|
|
105
|
+
): readonly { readonly id: string; readonly score: number }[] => {
|
|
106
|
+
const scores = new Map<string, number>();
|
|
107
|
+
for (const list of lists) {
|
|
108
|
+
list.forEach((item, index) => {
|
|
109
|
+
const rank = index + 1;
|
|
110
|
+
scores.set(item.id, (scores.get(item.id) ?? 0) + 1 / (k + rank));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return [...scores.entries()]
|
|
114
|
+
.map(([id, score]) => ({ id, score }))
|
|
115
|
+
.sort((left, right) => right.score - left.score);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const formatHitsText = (
|
|
119
|
+
query: string,
|
|
120
|
+
hits: readonly SearchHit[],
|
|
121
|
+
): string => {
|
|
122
|
+
if (hits.length === 0) {
|
|
123
|
+
return `Question:\n${query}\n\nNo hits.`;
|
|
124
|
+
}
|
|
125
|
+
const blocks = hits.map((hit, index) => {
|
|
126
|
+
const heading = hit.heading.length > 0 ? hit.heading : '(intro)';
|
|
127
|
+
const score = hit.score.toFixed(2);
|
|
128
|
+
return `${String(index + 1)}. ${hit.path} — ${heading} (${score})\n${hit.text}`;
|
|
129
|
+
});
|
|
130
|
+
return `Question:\n${query}\n\nContext:\n${blocks.join('\n\n')}`;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const hasFtsTable = (db: DatabaseSync): boolean => {
|
|
134
|
+
const row = db
|
|
135
|
+
.prepare(
|
|
136
|
+
`SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'chunk_fts'`,
|
|
137
|
+
)
|
|
138
|
+
.get() as { ok?: unknown } | undefined;
|
|
139
|
+
return row !== undefined;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const ftsRankedIds = (
|
|
143
|
+
db: DatabaseSync,
|
|
144
|
+
query: string,
|
|
145
|
+
limit: number,
|
|
146
|
+
): readonly RankedId[] => {
|
|
147
|
+
if (!hasFtsTable(db)) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const match = ftsMatchQuery(query);
|
|
151
|
+
if (match === undefined) {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const rows = db
|
|
156
|
+
.prepare('SELECT id FROM chunk_fts WHERE chunk_fts MATCH ? LIMIT ?')
|
|
157
|
+
.all(match, limit);
|
|
158
|
+
return rows.flatMap((row) => {
|
|
159
|
+
if (
|
|
160
|
+
row === null ||
|
|
161
|
+
typeof row !== 'object' ||
|
|
162
|
+
!('id' in row) ||
|
|
163
|
+
typeof (row as { id: unknown }).id !== 'string'
|
|
164
|
+
) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
return [{ id: (row as { id: string }).id }];
|
|
168
|
+
});
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const runSearch = async (
|
|
175
|
+
options: RunSearchOptions,
|
|
176
|
+
): Promise<{
|
|
177
|
+
readonly hits: readonly SearchHit[];
|
|
178
|
+
readonly text: string;
|
|
179
|
+
}> => {
|
|
180
|
+
const query = options.query.trim();
|
|
181
|
+
if (!fs.existsSync(options.sqlitePath)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`SQLite DB not found: ${options.sqlitePath}. Run kb-ingest first.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (query.length === 0) {
|
|
187
|
+
return { hits: [], text: formatHitsText(query, []) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const db = new DatabaseSync(options.sqlitePath);
|
|
191
|
+
try {
|
|
192
|
+
const candidateLimit = retrieveCandidateLimit(options.topK);
|
|
193
|
+
const vecRows = db
|
|
194
|
+
.prepare('SELECT id, dim, vector FROM chunk_vec')
|
|
195
|
+
.all()
|
|
196
|
+
.map((row) => asVecRow(row));
|
|
197
|
+
const mtimeMs = fs.statSync(options.sqlitePath).mtimeMs;
|
|
198
|
+
const index = loadVecIndex(
|
|
199
|
+
options.sqlitePath,
|
|
200
|
+
vecRows.map((row) => ({
|
|
201
|
+
id: row.id,
|
|
202
|
+
dim: row.dim,
|
|
203
|
+
vector: row.vector,
|
|
204
|
+
})),
|
|
205
|
+
mtimeMs,
|
|
206
|
+
);
|
|
207
|
+
let cosineList: readonly RankedId[] = [];
|
|
208
|
+
if (index.dim > 0) {
|
|
209
|
+
const [raw] = await options.embedder.embedTexts([query]);
|
|
210
|
+
if (raw === undefined) {
|
|
211
|
+
throw new Error('Embedding returned no vector');
|
|
212
|
+
}
|
|
213
|
+
if (raw.length !== index.dim) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Embedding dim ${String(raw.length)} does not match stored dim ${String(index.dim)}. Re-run ingest after switching models.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const queryVec = l2Normalize([...raw]);
|
|
219
|
+
cosineList = topCosine(index, queryVec, candidateLimit).map(
|
|
220
|
+
(item) => ({ id: item.id }),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const ftsList = ftsRankedIds(db, query, candidateLimit);
|
|
224
|
+
const fused = reciprocalRankFusion(
|
|
225
|
+
[cosineList, ftsList].filter((list) => list.length > 0),
|
|
226
|
+
RRF_K,
|
|
227
|
+
).slice(0, options.topK);
|
|
228
|
+
const byId = new Map(
|
|
229
|
+
db
|
|
230
|
+
.prepare('SELECT id, path, heading, text FROM chunk')
|
|
231
|
+
.all()
|
|
232
|
+
.map((row) => {
|
|
233
|
+
const parsed = asChunkRow(row);
|
|
234
|
+
return [parsed.id, parsed] as const;
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
const hits: SearchHit[] = [];
|
|
238
|
+
for (const item of fused) {
|
|
239
|
+
const chunk = byId.get(item.id);
|
|
240
|
+
if (chunk === undefined) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
hits.push({
|
|
244
|
+
path: chunk.path,
|
|
245
|
+
heading: chunk.heading,
|
|
246
|
+
score: item.score,
|
|
247
|
+
text: chunk.text,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return { hits, text: formatHitsText(query, hits) };
|
|
251
|
+
} finally {
|
|
252
|
+
db.close();
|
|
253
|
+
}
|
|
254
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EmbedHandle, EmbedTextRole } from '@langflower/node-sdk';
|
|
2
|
+
|
|
3
|
+
export type TextEmbedder = {
|
|
4
|
+
readonly expectedDim?: number;
|
|
5
|
+
embedTexts: (texts: readonly string[]) => Promise<readonly Float32Array[]>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const textEmbedderFromHandle = (
|
|
9
|
+
handle: EmbedHandle,
|
|
10
|
+
role: EmbedTextRole,
|
|
11
|
+
): TextEmbedder => ({
|
|
12
|
+
expectedDim: handle.dim,
|
|
13
|
+
embedTexts: (texts) => handle.embedTexts(texts, { role }),
|
|
14
|
+
});
|