feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save, dirty tracking, save-on-blur - PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load - CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header - FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading, file type routing (code→editor, pdf→viewer, csv→table, images→display) - 10MB size gate, binary detection, base64 encoding for non-text files
This commit is contained in:
parent
29a3370e79
commit
252fca70df
22 changed files with 8116 additions and 227 deletions
202
ui-electrobun/src/bun/search-db.ts
Normal file
202
ui-electrobun/src/bun/search-db.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* FTS5 full-text search database — bun:sqlite.
|
||||
*
|
||||
* Three virtual tables: search_messages, search_tasks, search_btmsg.
|
||||
* Provides indexing and unified search across all tables.
|
||||
* DB path: ~/.local/share/agor/search.db
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { homedir } from "os";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SearchResult {
|
||||
resultType: string;
|
||||
id: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ── DB path ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DATA_DIR = join(homedir(), ".local", "share", "agor");
|
||||
const DB_PATH = join(DATA_DIR, "search.db");
|
||||
|
||||
// ── SearchDb class ───────────────────────────────────────────────────────────
|
||||
|
||||
export class SearchDb {
|
||||
private db: Database;
|
||||
|
||||
constructor(dbPath?: string) {
|
||||
const path = dbPath ?? DB_PATH;
|
||||
const dir = join(path, "..");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
this.db = new Database(path);
|
||||
this.db.run("PRAGMA journal_mode = WAL");
|
||||
this.db.run("PRAGMA busy_timeout = 2000");
|
||||
this.createTables();
|
||||
}
|
||||
|
||||
private createTables(): void {
|
||||
this.db.run(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_messages USING fts5(
|
||||
session_id,
|
||||
role,
|
||||
content,
|
||||
timestamp
|
||||
)
|
||||
`);
|
||||
this.db.run(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_tasks USING fts5(
|
||||
task_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
assigned_to
|
||||
)
|
||||
`);
|
||||
this.db.run(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_btmsg USING fts5(
|
||||
message_id,
|
||||
from_agent,
|
||||
to_agent,
|
||||
content,
|
||||
channel_name
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
/** Index an agent message. */
|
||||
indexMessage(sessionId: string, role: string, content: string): void {
|
||||
const ts = new Date().toISOString();
|
||||
this.db.run(
|
||||
"INSERT INTO search_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
||||
[sessionId, role, content, ts],
|
||||
);
|
||||
}
|
||||
|
||||
/** Index a task. */
|
||||
indexTask(taskId: string, title: string, description: string, status: string, assignedTo: string): void {
|
||||
this.db.run(
|
||||
"INSERT INTO search_tasks (task_id, title, description, status, assigned_to) VALUES (?, ?, ?, ?, ?)",
|
||||
[taskId, title, description, status, assignedTo],
|
||||
);
|
||||
}
|
||||
|
||||
/** Index a btmsg message. */
|
||||
indexBtmsg(msgId: string, fromAgent: string, toAgent: string, content: string, channel: string): void {
|
||||
this.db.run(
|
||||
"INSERT INTO search_btmsg (message_id, from_agent, to_agent, content, channel_name) VALUES (?, ?, ?, ?, ?)",
|
||||
[msgId, fromAgent, toAgent, content, channel],
|
||||
);
|
||||
}
|
||||
|
||||
/** Search across all FTS5 tables. */
|
||||
searchAll(query: string, limit = 20): SearchResult[] {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// Search messages
|
||||
try {
|
||||
const msgRows = this.db
|
||||
.prepare(
|
||||
`SELECT session_id, role,
|
||||
snippet(search_messages, 2, '<b>', '</b>', '...', 32) as snip,
|
||||
rank
|
||||
FROM search_messages
|
||||
WHERE search_messages MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(query, limit) as Array<{ session_id: string; role: string; snip: string; rank: number }>;
|
||||
|
||||
for (const row of msgRows) {
|
||||
results.push({
|
||||
resultType: "message",
|
||||
id: row.session_id,
|
||||
title: row.role,
|
||||
snippet: row.snip ?? "",
|
||||
score: Math.abs(row.rank ?? 0),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// FTS5 syntax error — skip messages
|
||||
}
|
||||
|
||||
// Search tasks
|
||||
try {
|
||||
const taskRows = this.db
|
||||
.prepare(
|
||||
`SELECT task_id, title,
|
||||
snippet(search_tasks, 2, '<b>', '</b>', '...', 32) as snip,
|
||||
rank
|
||||
FROM search_tasks
|
||||
WHERE search_tasks MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(query, limit) as Array<{ task_id: string; title: string; snip: string; rank: number }>;
|
||||
|
||||
for (const row of taskRows) {
|
||||
results.push({
|
||||
resultType: "task",
|
||||
id: row.task_id,
|
||||
title: row.title,
|
||||
snippet: row.snip ?? "",
|
||||
score: Math.abs(row.rank ?? 0),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// FTS5 syntax error — skip tasks
|
||||
}
|
||||
|
||||
// Search btmsg
|
||||
try {
|
||||
const btmsgRows = this.db
|
||||
.prepare(
|
||||
`SELECT message_id, from_agent,
|
||||
snippet(search_btmsg, 3, '<b>', '</b>', '...', 32) as snip,
|
||||
rank
|
||||
FROM search_btmsg
|
||||
WHERE search_btmsg MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(query, limit) as Array<{ message_id: string; from_agent: string; snip: string; rank: number }>;
|
||||
|
||||
for (const row of btmsgRows) {
|
||||
results.push({
|
||||
resultType: "btmsg",
|
||||
id: row.message_id,
|
||||
title: row.from_agent,
|
||||
snippet: row.snip ?? "",
|
||||
score: Math.abs(row.rank ?? 0),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// FTS5 syntax error — skip btmsg
|
||||
}
|
||||
|
||||
// Sort by score (lower = more relevant for FTS5 rank)
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Drop and recreate all FTS5 tables. */
|
||||
rebuildIndex(): void {
|
||||
this.db.run("DROP TABLE IF EXISTS search_messages");
|
||||
this.db.run("DROP TABLE IF EXISTS search_tasks");
|
||||
this.db.run("DROP TABLE IF EXISTS search_btmsg");
|
||||
this.createTables();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue