mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
search bar
This commit is contained in:
parent
f22087cbc3
commit
a1b6789f6c
5 changed files with 645 additions and 1 deletions
|
|
@ -30,6 +30,7 @@ import * as composioHandler from './composio-handler.js';
|
|||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
import { search } from '@x/core/dist/search/search.js';
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
|
@ -497,5 +498,9 @@ export function setupIpcHandlers() {
|
|||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||
},
|
||||
// Search handler
|
||||
'search:query': async (_event, args) => {
|
||||
return search(args.query, args.limit, args.types);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
|||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { Button } from './components/ui/button';
|
||||
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { ChatInputBar } from './components/chat-button';
|
||||
|
|
@ -50,6 +50,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"
|
|||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
|
|
@ -675,6 +676,9 @@ function App() {
|
|||
// Onboarding state
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
|
||||
// Search state
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
|
||||
// Background tasks state
|
||||
type BackgroundTaskItem = {
|
||||
name: string
|
||||
|
|
@ -1829,6 +1833,18 @@ function App() {
|
|||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
|
||||
|
||||
// Keyboard shortcut: Cmd+K / Ctrl+K to open search
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setIsSearchOpen(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
navigateToFile(path)
|
||||
|
|
@ -2336,6 +2352,14 @@ function App() {
|
|||
<span className="text-sm font-medium text-muted-foreground flex-1 min-w-0 truncate">
|
||||
{headerTitle}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSearchOpen(true)}
|
||||
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
aria-label="Search"
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
</button>
|
||||
{selectedPath && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{isSaving ? (
|
||||
|
|
@ -2568,6 +2592,12 @@ function App() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
<SearchDialog
|
||||
open={isSearchOpen}
|
||||
onOpenChange={setIsSearchOpen}
|
||||
onSelectFile={navigateToFile}
|
||||
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
|
||||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<OnboardingModal
|
||||
|
|
|
|||
218
apps/x/apps/renderer/src/components/search-dialog.tsx
Normal file
218
apps/x/apps/renderer/src/components/search-dialog.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from '@/components/ui/command'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useSidebarSection, type ActiveSection } from '@/contexts/sidebar-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SearchResult {
|
||||
type: 'knowledge' | 'chat'
|
||||
title: string
|
||||
preview: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type SearchType = 'knowledge' | 'chat'
|
||||
|
||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||
if (section === 'knowledge') return ['knowledge']
|
||||
return ['chat'] // "tasks" tab maps to chat
|
||||
}
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
||||
() => new Set(activeTabToTypes(activeSection))
|
||||
)
|
||||
const debouncedQuery = useDebounce(query, 250)
|
||||
|
||||
// Sync filter preselection when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||
}
|
||||
}, [open, activeSection])
|
||||
|
||||
const toggleType = useCallback((type: SearchType) => {
|
||||
setActiveTypes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(type)) {
|
||||
// Don't allow deselecting all
|
||||
if (next.size > 1) {
|
||||
next.delete(type)
|
||||
}
|
||||
} else {
|
||||
next.add(type)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery.trim()) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setIsSearching(true)
|
||||
|
||||
const types = Array.from(activeTypes) as ('knowledge' | 'chat')[]
|
||||
window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setResults(res.results)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Search failed:', err)
|
||||
if (!cancelled) {
|
||||
setResults([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [debouncedQuery, activeTypes])
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSelect = useCallback((result: SearchResult) => {
|
||||
onOpenChange(false)
|
||||
if (result.type === 'knowledge') {
|
||||
onSelectFile(result.path)
|
||||
} else {
|
||||
onSelectRun(result.path)
|
||||
}
|
||||
}, [onOpenChange, onSelectFile, onSelectRun])
|
||||
|
||||
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
||||
const chatResults = results.filter(r => r.type === 'chat')
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Search"
|
||||
description="Search across knowledge and chats"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
375
apps/x/packages/core/src/search/search.ts
Normal file
375
apps/x/packages/core/src/search/search.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import readline from 'readline';
|
||||
import { execFile } from 'child_process';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
interface SearchResult {
|
||||
type: 'knowledge' | 'chat';
|
||||
title: string;
|
||||
preview: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
const RUNS_DIR = path.join(WorkDir, 'runs');
|
||||
|
||||
type SearchType = 'knowledge' | 'chat';
|
||||
|
||||
/**
|
||||
* Search across knowledge files and chat history.
|
||||
* @param types - optional filter to search only specific types (default: both)
|
||||
*/
|
||||
export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return { results: [] };
|
||||
}
|
||||
|
||||
const searchKnowledgeEnabled = !types || types.includes('knowledge');
|
||||
const searchChatsEnabled = !types || types.includes('chat');
|
||||
|
||||
const [knowledgeResults, chatResults] = await Promise.all([
|
||||
searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
|
||||
searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const results = [...knowledgeResults, ...chatResults].slice(0, limit);
|
||||
return { results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search knowledge markdown files by content and filename.
|
||||
*/
|
||||
async function searchKnowledge(query: string, limit: number): Promise<SearchResult[]> {
|
||||
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Content search via grep
|
||||
try {
|
||||
const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md');
|
||||
for (const match of grepMatches) {
|
||||
if (results.length >= limit) break;
|
||||
const relPath = path.relative(WorkDir, match.file);
|
||||
if (seenPaths.has(relPath)) continue;
|
||||
seenPaths.add(relPath);
|
||||
|
||||
const title = path.basename(match.file, '.md');
|
||||
results.push({
|
||||
type: 'knowledge',
|
||||
title,
|
||||
preview: match.line.trim().substring(0, 150),
|
||||
path: relPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// grep failed (no matches or dir issue) — continue
|
||||
}
|
||||
|
||||
// Filename search — check files whose name matches the query
|
||||
try {
|
||||
const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR);
|
||||
for (const file of allFiles) {
|
||||
if (results.length >= limit) break;
|
||||
const relPath = path.relative(WorkDir, file);
|
||||
if (seenPaths.has(relPath)) continue;
|
||||
|
||||
const basename = path.basename(file, '.md');
|
||||
if (basename.toLowerCase().includes(lowerQuery)) {
|
||||
seenPaths.add(relPath);
|
||||
const preview = await readFirstLines(file, 2);
|
||||
results.push({
|
||||
type: 'knowledge',
|
||||
title: basename,
|
||||
preview,
|
||||
path: relPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search chat history by title and message content.
|
||||
*/
|
||||
async function searchChats(query: string, limit: number): Promise<SearchResult[]> {
|
||||
if (!fs.existsSync(RUNS_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Content search via grep on JSONL files
|
||||
try {
|
||||
const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl');
|
||||
for (const match of grepMatches) {
|
||||
if (results.length >= limit) break;
|
||||
const runId = path.basename(match.file, '.jsonl');
|
||||
if (seenIds.has(runId)) continue;
|
||||
|
||||
const meta = await readRunMetadata(match.file);
|
||||
if (meta.agentName !== 'copilot') {
|
||||
seenIds.add(runId);
|
||||
continue;
|
||||
}
|
||||
seenIds.add(runId);
|
||||
|
||||
// Extract a content preview from the matching line
|
||||
let preview = '';
|
||||
try {
|
||||
const parsed = JSON.parse(match.line);
|
||||
if (parsed.message?.content && typeof parsed.message.content === 'string') {
|
||||
preview = parsed.message.content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150);
|
||||
}
|
||||
} catch {
|
||||
preview = match.line.substring(0, 150);
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: 'chat',
|
||||
title: meta.title || runId,
|
||||
preview,
|
||||
path: runId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// grep failed — continue
|
||||
}
|
||||
|
||||
// Title search — scan run files for matching titles
|
||||
try {
|
||||
const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
|
||||
const jsonlFiles = entries
|
||||
.filter(e => e.isFile() && e.name.endsWith('.jsonl'))
|
||||
.map(e => e.name)
|
||||
.sort()
|
||||
.reverse(); // newest first
|
||||
|
||||
for (const name of jsonlFiles) {
|
||||
if (results.length >= limit) break;
|
||||
const runId = path.basename(name, '.jsonl');
|
||||
if (seenIds.has(runId)) continue;
|
||||
|
||||
const filePath = path.join(RUNS_DIR, name);
|
||||
const meta = await readRunMetadata(filePath);
|
||||
if (meta.agentName !== 'copilot') {
|
||||
seenIds.add(runId);
|
||||
continue;
|
||||
}
|
||||
if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
|
||||
seenIds.add(runId);
|
||||
results.push({
|
||||
type: 'chat',
|
||||
title: meta.title,
|
||||
preview: meta.title,
|
||||
path: runId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use grep to find files matching a query.
|
||||
*/
|
||||
function grepFiles(query: string, dir: string, includeGlob: string): Promise<Array<{ file: string; line: string }>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'grep',
|
||||
['-ril', '--include=' + includeGlob, query, dir],
|
||||
{ maxBuffer: 1024 * 1024 },
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
// Exit code 1 = no matches
|
||||
if (error.code === 1) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = stdout.trim().split('\n').filter(Boolean);
|
||||
// For each matching file, get the first matching line
|
||||
const promises = files.map(file =>
|
||||
getFirstMatchingLine(file, query).then(line => ({ file, line }))
|
||||
);
|
||||
Promise.all(promises).then(resolve).catch(reject);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first line in a file that matches the query (case-insensitive).
|
||||
*/
|
||||
function getFirstMatchingLine(filePath: string, query: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const done = (value: string) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
rl.on('line', (line) => {
|
||||
if (line.toLowerCase().includes(lowerQuery)) {
|
||||
done(line);
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => done(''));
|
||||
stream.on('error', () => done(''));
|
||||
});
|
||||
}
|
||||
|
||||
interface RunMetadata {
|
||||
title: string | undefined;
|
||||
agentName: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read metadata from a run JSONL file (agent name from start event, title from first user message).
|
||||
*/
|
||||
function readRunMetadata(filePath: string): Promise<RunMetadata> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const done = (value: RunMetadata) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
let lineIndex = 0;
|
||||
let agentName: string | undefined;
|
||||
|
||||
rl.on('line', (line) => {
|
||||
if (resolved) return;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
try {
|
||||
if (lineIndex === 0) {
|
||||
// Start event — extract agentName
|
||||
const start = JSON.parse(trimmed);
|
||||
agentName = start.agentName;
|
||||
lineIndex++;
|
||||
return;
|
||||
}
|
||||
|
||||
const event = JSON.parse(trimmed);
|
||||
if (event.type === 'message') {
|
||||
const msg = event.message;
|
||||
if (msg?.role === 'user') {
|
||||
const content = msg.content;
|
||||
if (typeof content === 'string' && content.trim()) {
|
||||
let cleaned = content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '');
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
||||
if (cleaned) {
|
||||
done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName });
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
done({ title: undefined, agentName });
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return;
|
||||
} else if (msg?.role === 'assistant') {
|
||||
done({ title: undefined, agentName });
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
lineIndex++;
|
||||
} catch {
|
||||
lineIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => done({ title: undefined, agentName }));
|
||||
rl.on('error', () => done({ title: undefined, agentName: undefined }));
|
||||
stream.on('error', () => {
|
||||
rl.close();
|
||||
done({ title: undefined, agentName: undefined });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively list all .md files in a directory.
|
||||
*/
|
||||
async function listMarkdownFiles(dir: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const nested = await listMarkdownFiles(fullPath);
|
||||
results.push(...nested);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first N non-empty lines of a file for preview.
|
||||
*/
|
||||
async function readFirstLines(filePath: string, n: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
const lines: string[] = [];
|
||||
|
||||
rl.on('line', (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
if (lines.length >= n) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
resolve(lines.join(' ').substring(0, 150));
|
||||
});
|
||||
|
||||
stream.on('error', () => {
|
||||
resolve('');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -396,6 +396,22 @@ const ipcSchemas = {
|
|||
req: z.object({ path: z.string() }),
|
||||
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
||||
},
|
||||
// Search channels
|
||||
'search:query': {
|
||||
req: z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().optional(),
|
||||
types: z.array(z.enum(['knowledge', 'chat'])).optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
results: z.array(z.object({
|
||||
type: z.enum(['knowledge', 'chat']),
|
||||
title: z.string(),
|
||||
preview: z.string(),
|
||||
path: z.string(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue