feat: initial commit

This commit is contained in:
Menci
2026-01-01 03:40:41 +08:00
commit 631f8ed771
98 changed files with 14776 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MaigoLabs :: needLe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@maigolabs/needle-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"typecheck": "tsc",
"dev": "vite --port 5172",
"build": "tsc -b && vite build"
},
"license": "AGPL-3.0",
"packageManager": "pnpm@10.20.0",
"private": true,
"dependencies": {
"@maigolabs/needle": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@iconify-json/svg-spinners": "^1.2.4",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"unocss": "^66.5.12",
"vite": "^7.2.4",
"vite-plugin-top-level-await": "^1.6.0"
}
}
+1
View File
@@ -0,0 +1 @@
/fonts
+168
View File
@@ -0,0 +1,168 @@
import { TokenType } from '@maigolabs/needle/common';
import {
searchInvertedIndex,
highlightSearchResult,
type SearchResult,
} from '@maigolabs/needle/searcher';
import { useState, type FunctionComponent } from 'react';
type Tab = 'search' | 'tokenize';
type AppData = typeof import('./data');
export const Layout: FunctionComponent<{ dataPromise: Promise<AppData> }> = ({ dataPromise }) => {
const [appData, setAppData] = useState<AppData | null>(null);
const [error, setError] = useState<string | null>(null);
void dataPromise.then(props => setAppData(props)).catch(error => setError((error instanceof Error ? error.stack : undefined) ?? String(error)));
return (
<div className="min-h-screen bg-[#f9f2e0] text-[#8b7355] font-mono selection:bg-[#d4c4b0]/70">
<div className="max-w-200 mx-auto px-4 pt-8 pb-6">
<header className="mb-8">
<h1 className="pb-3 text-2xl text-[#a08060]">MaigoLabs :: needLe</h1>
<div className="pb-4 text-sm">
<p>Fuzzy search engine for small text pieces, with Chinese/Japanese pronunciation support</p>
<p>(Available in TypeScript and C#)</p>
</div>
<div className="flex gap-4 text-sm">
<a href="https://github.com/MaigoLabs/needLe" target="_blank" rel="noopener" className="text-[#b8a890] hover:text-[#8b7355]">[GitHub]</a>
<a href="https://www.npmjs.com/package/@maigolabs/needle" target="_blank" rel="noopener" className="text-[#b8a890] hover:text-[#8b7355]">[NPM]</a>
<a href="https://www.nuget.org/packages/MaigoLabs.NeedLe" target="_blank" rel="noopener" className="text-[#b8a890] hover:text-[#8b7355]">[NuGet]</a>
</div>
</header>
{
appData
? <App appData={appData} />
: error
? <div className="text-sm bg-[#efe5d0] px-4 py-3 rounded-lg whitespace-pre-wrap">{error}</div>
: <div>
<div className="flex flex-row items-center gap-2"><div className="i-svg-spinners:ring-resize" /> Loading...</div>
<div className="mt-6 text-sm bg-[#efe5d0] px-4 py-3 rounded-lg">
<div className="font-bold mb-2">Tips:</div>
<div>This demo loads Kuromoji/OpenCC/pinyin-pro for tokenization and index building.</div>
<div>However, searching on a prebuilt index doesn't require loading any external library/dictionary.</div>
</div>
</div>
}
</div>
</div>
);
};
interface AppProps {
appData: AppData;
}
export const App: FunctionComponent<AppProps> = ({ appData: { kuromoji, createTokenizer, invertedIndex } }) => {
const [input, setInput] = useState('');
const [tab, setTab] = useState<Tab>('search');
const searchResults = tab === 'search' && input.trim()
? searchInvertedIndex(invertedIndex, input).slice(0, 50)
: [];
const tokenizeResults = tab === 'tokenize' && input.trim()
? (() => {
const tokenizer = createTokenizer({ kuromoji });
const tokens = tokenizer.tokenize(input);
const tokenDefs = tokenizer.tokens;
const codePoints = [...input];
return tokens.map(t => {
const def = [...tokenDefs.values()].find(d => d.id === t.id)!;
const original = codePoints.slice(t.start, t.end).join('');
return { ...t, type: def.type, text: def.text, original };
});
})()
: [];
return (
<>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder={`Type something to ${tab}...`}
className="w-full bg-[#efe5d0] text-[#6b5a48] px-3 py-2 mb-2 outline-none placeholder-[#b8a890] rounded-lg"
/>
<div className="flex gap-4 mb-6 text-sm">
<button
onClick={() => setTab('search')}
className={`bg-transparent border-none cursor-pointer ${tab === 'search' ? 'text-[#6b5a48]' : 'text-[#c0b0a0]'}`}
>
Search
</button>
<button
onClick={() => setTab('tokenize')}
className={`bg-transparent border-none cursor-pointer ${tab === 'tokenize' ? 'text-[#6b5a48]' : 'text-[#c0b0a0]'}`}
>
Tokenize
</button>
</div>
<div className="space-y-2">
{tab === 'search' && searchResults.map((result, i) => (
<SearchResultItem key={i} result={result} input={input} />
))}
{tab === 'tokenize' && tokenizeResults.length > 0 && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-1">
{tokenizeResults.map((token, i) => (
<div key={i} className="bg-[#efe5d0] px-3 py-2 text-sm truncate rounded-lg">
<span className="text-[#a08060]">{TokenType[token.type]}: </span>
<span className="text-[#6b5a48]">{JSON.stringify(token.text)}</span>
<span className="text-[#c0b0a0]">{' <- '}</span>
<span className="text-[#8b7355]">{JSON.stringify(token.original)}</span>
<span className="text-[#c8bba8]">{` [${token.start}, ${token.end}]`}</span>
</div>
))}
</div>
)}
{input.trim() && tab === 'search' && searchResults.length === 0 && (
<div className="text-[#b8a890] text-sm">No results.</div>
)}
</div>
</>
);
};
const SearchResultItem: FunctionComponent<{ result: SearchResult; input: string }> = ({ result, input }) => {
const highlighted = highlightSearchResult(result);
const inputCodePoints = [...input];
const stats = [
`${result.rangeCount} range(s)`,
`${Math.round(result.matchRatio * 100)}%`,
result.prefixMatchCount > 0 ? `${result.prefixMatchCount} prefix` : null,
].filter(Boolean).join(', ');
return (
<div className="bg-[#efe5d0] px-3 py-2 text-sm rounded-lg">
<div className="flex gap-2">
<div className="flex-1 truncate">
{highlighted.map((part, i) =>
typeof part === 'string'
? <span key={i} className="text-[#b8a890]">{part}</span>
: <span key={i} className="text-[#5a4a38]">{part.highlight}</span>)}
</div>
<div className="text-[#c8bba8] shrink-0">{stats}</div>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-x-2 mt-1">
{result.tokens.map((token, i) => {
const inputText = inputCodePoints.slice(token.inputOffset.start, token.inputOffset.end).join('');
const docText = result.documentCodePoints.slice(token.documentOffset.start, token.documentOffset.end).join('');
return (
<div key={i} className="text-[11px] truncate">
<span className="text-[#b8a890]">{TokenType[token.definition.type]}: </span>
<span className="text-[#8b7355]">{JSON.stringify(inputText)}</span>
<span className="text-[#c8bba8]">{' -> '}</span>
<span className="text-[#6b5a48]">{JSON.stringify(docText)}</span>
{token.isTokenPrefixMatching && <span className="text-[#b8a890]">{' (prefix)'}</span>}
</div>
);
})}
</div>
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import { buildInvertedIndex } from '@maigolabs/needle/indexer';
import { loadInvertedIndex } from '@maigolabs/needle/searcher';
import { TokenizerBuilder } from '@patdx/kuromoji';
// Indexer loads OpenCC and pinyin-pro which is large, put them in data.ts for dynamic importing.
export { createTokenizer } from '@maigolabs/needle/indexer';
const musicNames: string[] = [...new Set(
Object.values(
await (await fetch('https://sekai-world.github.io/sekai-master-db-diff/musics.json')).json(),
).map(music => (music as { title: string }).title),
)];
export const kuromoji = await new TokenizerBuilder({
loader: {
loadArrayBuffer: async (url: string) => {
url = `https://cdn.jsdelivr.net/npm/@aiktb/kuromoji@1.0.2/dict/${url.replace('.gz', '')}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}`);
return await res.arrayBuffer();
},
},
}).build();
export const compressed = buildInvertedIndex(musicNames, { kuromoji });
export const invertedIndex = loadInvertedIndex(compressed);
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Layout } from './App';
import 'virtual:uno.css';
import '@unocss/reset/tailwind.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Layout dataPromise={import('./data')} />
</StrictMode>,
);
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
"types": ["vite/client"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["dist", "node_modules"]
}
+30
View File
@@ -0,0 +1,30 @@
import { createLocalFontProcessor } from '@unocss/preset-web-fonts/local';
import { defineConfig, presetWind3, presetTypography, presetWebFonts, transformerVariantGroup, transformerDirectives, presetIcons } from 'unocss';
export default defineConfig({
presets: [
presetWind3(),
presetTypography(),
presetIcons({
scale: 1.2,
warn: true,
}),
presetWebFonts({
fonts: {
mono: {
name: 'Maple Mono',
provider: 'fontsource',
},
},
processors: createLocalFontProcessor({
cacheDir: 'node_modules/.cache/unocss/fonts',
fontAssetsDir: 'public/assets/fonts/cache',
fontServeBaseUrl: '/assets/fonts/cache',
}),
}),
],
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import UnoCSS from 'unocss/vite'
import react from '@vitejs/plugin-react'
import topLevelAwait from 'vite-plugin-top-level-await'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), UnoCSS(), topLevelAwait()],
build: {
assetsInlineLimit: 0,
minify: true
},
})
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@maigolabs/needle-playground-bot",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc"
},
"license": "AGPL-3.0",
"packageManager": "pnpm@10.20.0",
"private": true,
"dependencies": {
"@maigolabs/needle": "workspace:*",
"telegraf": "^4.16.3"
},
"devDependencies": {
"@types/node": "^24.10.4"
}
}
+78
View File
@@ -0,0 +1,78 @@
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import { TokenType } from '@maigolabs/needle/common';
import { buildInvertedIndex, createTokenizer } from '@maigolabs/needle/indexer';
import { loadInvertedIndex, inspectSearchResult, searchInvertedIndex } from '@maigolabs/needle/searcher';
import { TokenizerBuilder } from '@patdx/kuromoji';
import NodeDictionaryLoader from '@patdx/kuromoji/node';
import { Telegraf } from 'telegraf';
const botToken = process.env.TELEGRAM_BOT_TOKEN!;
const targetChatId = parseInt(process.env.TARGET_CHAT_ID!);
if (!botToken || isNaN(targetChatId)) throw new Error('Missing environment variables TELEGRAM_BOT_TOKEN or TARGET_CHAT_ID');
const bot = new Telegraf(botToken);
const escapeHtml = (s: string) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const commands = await (async () => {
const kuromojiDictPath = path.resolve(url.fileURLToPath(import.meta.resolve('@patdx/kuromoji')), '..', '..', 'dict');
const kuromoji = await new TokenizerBuilder({ loader: new NodeDictionaryLoader({ dic_path: kuromojiDictPath }) }).build();
const documents = (await fs.promises.readFile('../../example.txt', 'utf-8')).split('\n').filter(line => line.length > 0);
const startBuildInvertedIndex = performance.now();
const compressed = buildInvertedIndex(documents, { kuromoji });
const endBuildInvertedIndex = performance.now();
console.log(`Built inverted index in ${endBuildInvertedIndex - startBuildInvertedIndex}ms`);
const startLoadInvertedIndex = performance.now();
const invertedIndex = loadInvertedIndex(compressed);
const endLoadInvertedIndex = performance.now();
console.log(`Loaded inverted index in ${endLoadInvertedIndex - startLoadInvertedIndex}ms`);
const codify = (text: string) => `<code>${escapeHtml(text)}</code>`;
return {
needle: (text: string) => {
const startSearch = performance.now();
const results = searchInvertedIndex(invertedIndex, text);
const endSearch = performance.now();
const searchDuration = (endSearch - startSearch).toFixed(3);
const showingResults = results.slice(0, 5);
return results.length === 0 ? codify(`No results found after ${searchDuration}ms`) : [
codify(`Search completed in ${searchDuration}ms, showing ${showingResults.length}/${results.length} results:\n`),
...showingResults.map(result => inspectSearchResult(result, true)),
].join('\n').trimEnd();
},
tokenize: (text: string) => {
const startTokenize = performance.now();
const tokenizer = createTokenizer({ kuromoji });
const tokens = tokenizer.tokenize(text);
const tokenDefinitions = [...tokenizer.tokens.values()];
const endTokenize = performance.now();
const tokenizeDuration = (endTokenize - startTokenize).toFixed(3);
return codify(tokens.length === 0 ? `No tokens emitted after ${tokenizeDuration}ms` : [
`Tokenization completed in ${tokenizeDuration}ms, emitted ${tokens.length} tokens:`,
...tokens
.map(token => [tokenDefinitions[token.id]!, token, [...text].slice(token.start, token.end).join('')] as const)
.map(([token, { start, end }, originalPhrase]) => ` ${TokenType[token.type]}: ${JSON.stringify(token.text)} <- ${JSON.stringify(originalPhrase)} [${start}, ${end}]`),
].join('\n'));
},
};
})();
bot.on('message', async ctx => {
const text = 'text' in ctx.message ? ctx.message.text : undefined;
console.log(`${ctx.chat.id ?? 'N/A'}:${ctx.from!.id} ${JSON.stringify(text)}`);
if (ctx.chat.id === targetChatId) {
if (text?.startsWith('/needle ')) {
await ctx.reply(commands.needle(text.slice('/needle '.length)), { parse_mode: 'HTML' });
} else if (text?.startsWith('/tokenize ')) {
await ctx.reply(commands.tokenize(text.slice('/tokenize '.length)), { parse_mode: 'HTML' });
}
}
});
await bot.launch();
void bot.telegram.getMe().then(me => console.log(`Bot logged in as ${me.first_name} (@${me.username})`));
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}