From fcca352f965666771d1459a3576beafbd9388859 Mon Sep 17 00:00:00 2001 From: DebjyotiRay Date: Fri, 18 Jul 2025 18:20:37 +0530 Subject: [PATCH 1/2] added the complete search functionality --- .../backend_node/routes/conversations.js | 15 + pickleglass_web/components/SearchPopup.tsx | 99 ++++++- pickleglass_web/utils/api.ts | 38 ++- .../search/repositories/sqlite.repository.js | 262 ++++++++++++++++++ src/features/search/searchService.js | 149 ++++++++++ src/index.js | 10 + 6 files changed, 559 insertions(+), 14 deletions(-) create mode 100644 src/features/search/repositories/sqlite.repository.js create mode 100644 src/features/search/searchService.js diff --git a/pickleglass_web/backend_node/routes/conversations.js b/pickleglass_web/backend_node/routes/conversations.js index 7ea9d8ac..3fbbd20c 100644 --- a/pickleglass_web/backend_node/routes/conversations.js +++ b/pickleglass_web/backend_node/routes/conversations.js @@ -12,6 +12,21 @@ router.get('/', async (req, res) => { } }); +router.get('/search', async (req, res) => { + try { + const query = req.query.q; + if (!query || query.trim().length === 0) { + return res.json([]); + } + + const result = await ipcRequest(req, 'search-content', { query: query.trim() }); + res.json(result); + } catch (error) { + console.error('Failed to search content via IPC:', error); + res.status(500).json({ error: 'Failed to search content' }); + } +}); + router.post('/', async (req, res) => { try { const result = await ipcRequest(req, 'create-session', req.body); diff --git a/pickleglass_web/components/SearchPopup.tsx b/pickleglass_web/components/SearchPopup.tsx index 551255dd..0792b5e2 100644 --- a/pickleglass_web/components/SearchPopup.tsx +++ b/pickleglass_web/components/SearchPopup.tsx @@ -3,8 +3,8 @@ import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { Search, X } from 'lucide-react' -import { searchConversations, Session } from '@/utils/api' -import { MessageSquare } from 'lucide-react' +import { searchConversations, SearchResult } from '@/utils/api' +import { MessageSquare, FileText, Mic } from 'lucide-react' interface SearchPopupProps { isOpen: boolean @@ -13,7 +13,7 @@ interface SearchPopupProps { export default function SearchPopup({ isOpen, onClose }: SearchPopupProps) { const [searchQuery, setSearchQuery] = useState('') - const [searchResults, setSearchResults] = useState([]) + const [searchResults, setSearchResults] = useState([]) const [isLoading, setIsLoading] = useState(false) const inputRef = useRef(null) const router = useRouter() @@ -65,6 +65,13 @@ export default function SearchPopup({ isOpen, onClose }: SearchPopupProps) { } } + const highlightSearchTerm = (text: string, searchTerm: string): string => { + if (!text || !searchTerm) return text + + const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi') + return text.replace(regex, '$1') + } + if (!isOpen) return null return ( @@ -112,25 +119,93 @@ export default function SearchPopup({ isOpen, onClose }: SearchPopupProps) {
{searchResults.map((result) => { const timestamp = new Date(result.started_at * 1000).toLocaleString() + const sessionTypeIcon = result.session_type === 'listen' ? Mic : MessageSquare + const SessionIcon = sessionTypeIcon return (
{ - router.push(`/activity/${result.id}`) + router.push(`/activity/details?sessionId=${result.id}`) onClose() }} >
- +
-

- {result.title || 'Untitled Conversation'} -

-
- {timestamp} +
+

+ {result.title || 'Untitled Conversation'} +

+
+ + {result.session_type} + +
+
+ + {/* Match information */} +
+ {timestamp} + {result.total_matches > 0 && ( +
+ {result.message_matches > 0 && ( + + + {result.message_matches} message{result.message_matches !== 1 ? 's' : ''} + + )} + {result.transcript_matches > 0 && ( + + + {result.transcript_matches} transcript{result.transcript_matches !== 1 ? 's' : ''} + + )} +
+ )}
+ + {/* Content previews */} + {result.previews && result.previews.length > 0 && ( +
+ {result.previews.slice(0, 2).map((preview, index) => ( +
+
+ {preview.type === 'ai_message' ? ( + + ) : ( + + )} + + {preview.type === 'ai_message' + ? (preview.role === 'user' ? 'You' : 'AI') + : (preview.role || 'Speaker') + } + +
+

+

+
+ ))} + {result.previews.length > 2 && ( +

+ +{result.previews.length - 2} more match{result.previews.length - 2 !== 1 ? 'es' : ''} +

+ )} +
+ )}
@@ -148,4 +223,4 @@ export default function SearchPopup({ isOpen, onClose }: SearchPopupProps) {
) -} \ No newline at end of file +} diff --git a/pickleglass_web/utils/api.ts b/pickleglass_web/utils/api.ts index 958325fb..70c8e565 100644 --- a/pickleglass_web/utils/api.ts +++ b/pickleglass_web/utils/api.ts @@ -78,6 +78,28 @@ export interface PromptPreset { sync_state: 'clean' | 'dirty'; } +export interface SearchResult { + id: string; + title: string; + session_type: string; + started_at: number; + ended_at?: number; + updated_at: number; + message_matches: number; + transcript_matches: number; + total_matches: number; + match_type: 'title' | 'content'; + previews: SearchPreview[]; +} + +export interface SearchPreview { + content: string; + role: string; + timestamp: number; + type: 'ai_message' | 'transcript'; + snippet: string; +} + export interface SessionDetails { session: Session; transcripts: Transcript[]; @@ -299,7 +321,7 @@ export const apiCall = async (path: string, options: RequestInit = {}) => { }; -export const searchConversations = async (query: string): Promise => { +export const searchConversations = async (query: string): Promise => { if (!query.trim()) { return []; } @@ -308,7 +330,19 @@ export const searchConversations = async (query: string): Promise => const sessions = await getSessions(); return sessions.filter(session => session.title.toLowerCase().includes(query.toLowerCase()) - ); + ).map(session => ({ + id: session.id, + title: session.title, + session_type: session.session_type, + started_at: session.started_at, + ended_at: session.ended_at, + updated_at: session.updated_at, + message_matches: 0, + transcript_matches: 0, + total_matches: 1, + match_type: 'title' as const, + previews: [] + })); } else { const response = await apiCall(`/api/conversations/search?q=${encodeURIComponent(query)}`, { method: 'GET', diff --git a/src/features/search/repositories/sqlite.repository.js b/src/features/search/repositories/sqlite.repository.js new file mode 100644 index 00000000..c912fbf2 --- /dev/null +++ b/src/features/search/repositories/sqlite.repository.js @@ -0,0 +1,262 @@ +const sqliteClient = require('../../common/services/sqliteClient'); + +class SearchRepository { + constructor() { + this.db = null; + } + + initialize() { + this.db = sqliteClient.getDb(); + } + + searchContent(query, uid, options = {}) { + if (!this.db) { + this.initialize(); + } + + const { + limit = 50, + includeTranscripts = true, + includeAiMessages = true, + sessionType = null + } = options; + + if (!query || query.trim().length === 0) { + return []; + } + + const searchTerm = `%${query.toLowerCase()}%`; + const results = []; + + // Search in AI messages + if (includeAiMessages) { + const aiMessageQuery = ` + SELECT + am.id, + am.session_id, + am.content, + am.role, + am.sent_at as timestamp, + am.model, + s.title as session_title, + s.session_type, + s.started_at as session_started_at, + 'ai_message' as content_type, + CASE + WHEN LOWER(am.content) LIKE ? THEN 10 + ELSE 5 + END as match_score + FROM ai_messages am + JOIN sessions s ON am.session_id = s.id + WHERE s.uid = ? + AND LOWER(am.content) LIKE ? + ${sessionType ? 'AND s.session_type = ?' : ''} + ORDER BY match_score DESC, am.sent_at DESC + LIMIT ? + `; + + const aiParams = sessionType + ? [searchTerm, uid, searchTerm, sessionType, limit] + : [searchTerm, uid, searchTerm, limit]; + + const aiResults = this.db.prepare(aiMessageQuery).all(...aiParams); + results.push(...aiResults); + } + + // Search in transcripts + if (includeTranscripts) { + const transcriptQuery = ` + SELECT + t.id, + t.session_id, + t.text as content, + t.speaker as role, + t.start_at as timestamp, + t.lang, + s.title as session_title, + s.session_type, + s.started_at as session_started_at, + 'transcript' as content_type, + CASE + WHEN LOWER(t.text) LIKE ? THEN 10 + ELSE 5 + END as match_score + FROM transcripts t + JOIN sessions s ON t.session_id = s.id + WHERE s.uid = ? + AND LOWER(t.text) LIKE ? + ${sessionType ? 'AND s.session_type = ?' : ''} + ORDER BY match_score DESC, t.start_at DESC + LIMIT ? + `; + + const transcriptParams = sessionType + ? [searchTerm, uid, searchTerm, sessionType, limit] + : [searchTerm, uid, searchTerm, limit]; + + const transcriptResults = this.db.prepare(transcriptQuery).all(...transcriptParams); + results.push(...transcriptResults); + } + + // Sort all results by match score and timestamp + return results + .sort((a, b) => { + if (a.match_score !== b.match_score) { + return b.match_score - a.match_score; + } + return b.timestamp - a.timestamp; + }) + .slice(0, limit); + } + + searchSessions(query, uid, options = {}) { + if (!this.db) { + this.initialize(); + } + + const { limit = 20 } = options; + + if (!query || query.trim().length === 0) { + return []; + } + + const searchTerm = `%${query.toLowerCase()}%`; + + // Search sessions and get match counts + const sessionQuery = ` + SELECT + s.id, + s.title, + s.session_type, + s.started_at, + s.ended_at, + s.updated_at, + COALESCE(am_counts.message_matches, 0) as message_matches, + COALESCE(t_counts.transcript_matches, 0) as transcript_matches, + CASE + WHEN LOWER(s.title) LIKE ? THEN 'title' + ELSE 'content' + END as match_type + FROM sessions s + LEFT JOIN ( + SELECT session_id, COUNT(*) as message_matches + FROM ai_messages + WHERE LOWER(content) LIKE ? + GROUP BY session_id + ) am_counts ON s.id = am_counts.session_id + LEFT JOIN ( + SELECT session_id, COUNT(*) as transcript_matches + FROM transcripts + WHERE LOWER(text) LIKE ? + GROUP BY session_id + ) t_counts ON s.id = t_counts.session_id + WHERE s.uid = ? + AND ( + LOWER(s.title) LIKE ? + OR am_counts.message_matches > 0 + OR t_counts.transcript_matches > 0 + ) + ORDER BY + CASE WHEN LOWER(s.title) LIKE ? THEN 1 ELSE 2 END, + (COALESCE(am_counts.message_matches, 0) + COALESCE(t_counts.transcript_matches, 0)) DESC, + s.updated_at DESC + LIMIT ? + `; + + return this.db.prepare(sessionQuery).all( + searchTerm, searchTerm, searchTerm, uid, searchTerm, searchTerm, limit + ); + } + + getSessionPreviews(sessionId, query, maxPreviews = 3) { + if (!this.db) { + this.initialize(); + } + + const searchTerm = `%${query.toLowerCase()}%`; + const previews = []; + + // Get AI message previews + const aiPreviewQuery = ` + SELECT content, role, sent_at as timestamp, 'ai_message' as type + FROM ai_messages + WHERE session_id = ? AND LOWER(content) LIKE ? + ORDER BY sent_at DESC + LIMIT ? + `; + + const aiPreviews = this.db.prepare(aiPreviewQuery).all(sessionId, searchTerm, maxPreviews); + previews.push(...aiPreviews); + + // Get transcript previews + const transcriptPreviewQuery = ` + SELECT text as content, speaker as role, start_at as timestamp, 'transcript' as type + FROM transcripts + WHERE session_id = ? AND LOWER(text) LIKE ? + ORDER BY start_at DESC + LIMIT ? + `; + + const transcriptPreviews = this.db.prepare(transcriptPreviewQuery).all(sessionId, searchTerm, maxPreviews); + previews.push(...transcriptPreviews); + + // Sort and limit previews + return previews + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, maxPreviews); + } + + getMessageContext(messageId, messageType, contextSize = 5) { + if (!this.db) { + this.initialize(); + } + + let targetMessage; + let sessionId; + + if (messageType === 'ai_message') { + targetMessage = this.db.prepare('SELECT * FROM ai_messages WHERE id = ?').get(messageId); + sessionId = targetMessage?.session_id; + } else if (messageType === 'transcript') { + targetMessage = this.db.prepare('SELECT * FROM transcripts WHERE id = ?').get(messageId); + sessionId = targetMessage?.session_id; + } + + if (!targetMessage || !sessionId) { + return null; + } + + // Get surrounding context + const contextQuery = ` + SELECT * FROM ( + SELECT id, content, role, sent_at as timestamp, 'ai_message' as type + FROM ai_messages + WHERE session_id = ? + UNION ALL + SELECT id, text as content, speaker as role, start_at as timestamp, 'transcript' as type + FROM transcripts + WHERE session_id = ? + ) + ORDER BY timestamp + `; + + const allMessages = this.db.prepare(contextQuery).all(sessionId, sessionId); + const targetIndex = allMessages.findIndex(msg => msg.id === messageId); + + if (targetIndex === -1) { + return { target: targetMessage, context: [] }; + } + + const start = Math.max(0, targetIndex - contextSize); + const end = Math.min(allMessages.length, targetIndex + contextSize + 1); + const context = allMessages.slice(start, end); + + return { + target: targetMessage, + context, + session_id: sessionId + }; + } +} + +module.exports = new SearchRepository(); diff --git a/src/features/search/searchService.js b/src/features/search/searchService.js new file mode 100644 index 00000000..c90dc8b8 --- /dev/null +++ b/src/features/search/searchService.js @@ -0,0 +1,149 @@ +const searchRepository = require('./repositories/sqlite.repository'); + +class SearchService { + async searchContent(query, uid, options = {}) { + if (!query || query.trim().length === 0) { + return []; + } + + const results = searchRepository.searchContent(query, uid, options); + return results.map(result => this.enhanceSearchResult(result, query)); + } + + async searchSessions(query, uid, options = {}) { + if (!query || query.trim().length === 0) { + return []; + } + + const sessions = searchRepository.searchSessions(query, uid, options); + + return sessions.map(session => { + const previews = searchRepository.getSessionPreviews(session.id, query, 2); + return { + ...session, + total_matches: session.message_matches + session.transcript_matches, + previews: previews.map(preview => ({ + ...preview, + snippet: this.createPreviewSnippet(preview.content, query) + })) + }; + }); + } + + async search(query, uid, options = {}) { + if (!query || query.trim().length === 0) { + return { + sessions: [], + content: [], + total_results: 0 + }; + } + + const [sessions, content] = await Promise.all([ + this.searchSessions(query, uid, { limit: options.sessionLimit || 10 }), + this.searchContent(query, uid, { limit: options.contentLimit || 20 }) + ]); + + return { + sessions, + content, + total_results: sessions.length + content.length, + query: query.trim() + }; + } + + async getMessageContext(messageId, messageType, contextSize = 5) { + return searchRepository.getMessageContext(messageId, messageType, contextSize); + } + + enhanceSearchResult(result, query) { + const snippet = this.createPreviewSnippet(result.content, query); + const highlightedSnippet = this.highlightKeywords(snippet, query); + + return { + ...result, + snippet, + highlighted_snippet: highlightedSnippet, + formatted_timestamp: new Date(result.timestamp * 1000).toISOString(), + session_formatted_timestamp: new Date(result.session_started_at * 1000).toISOString(), + display_role: this.formatRole(result.role, result.content_type) + }; + } + + createPreviewSnippet(content, query, maxLength = 150) { + if (!content || !query) { + return content ? content.substring(0, maxLength) + (content.length > maxLength ? '...' : '') : ''; + } + + const queryLower = query.toLowerCase(); + const contentLower = content.toLowerCase(); + + const index = contentLower.indexOf(queryLower); + if (index === -1) { + return content.substring(0, maxLength) + (content.length > maxLength ? '...' : ''); + } + + const contextBefore = 60; + const contextAfter = maxLength - query.length - contextBefore; + + const start = Math.max(0, index - contextBefore); + const end = Math.min(content.length, index + query.length + contextAfter); + + let snippet = content.substring(start, end); + + if (start > 0) snippet = '...' + snippet; + if (end < content.length) snippet = snippet + '...'; + + return snippet; + } + + highlightKeywords(text, query) { + if (!text || !query) return text; + + const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi'); + return text.replace(regex, '$1'); + } + + escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + formatRole(role, contentType) { + if (contentType === 'ai_message') { + return role === 'user' ? 'You' : 'AI Assistant'; + } else if (contentType === 'transcript') { + return role ? `${role} (Transcript)` : 'Transcript'; + } + return role || 'Unknown'; + } + + async getSearchSuggestions(uid, limit = 5) { + return [ + 'meeting notes', + 'action items', + 'decisions made', + 'follow up', + 'questions asked' + ].slice(0, limit); + } + + async getSearchStats(uid) { + const db = searchRepository.db || searchRepository.initialize() || searchRepository.db; + + const stats = db.prepare(` + SELECT + (SELECT COUNT(*) FROM sessions WHERE uid = ?) as total_sessions, + (SELECT COUNT(*) FROM ai_messages am JOIN sessions s ON am.session_id = s.id WHERE s.uid = ?) as total_messages, + (SELECT COUNT(*) FROM transcripts t JOIN sessions s ON t.session_id = s.id WHERE s.uid = ?) as total_transcripts, + (SELECT COUNT(*) FROM sessions WHERE uid = ? AND session_type = 'ask') as ask_sessions, + (SELECT COUNT(*) FROM sessions WHERE uid = ? AND session_type = 'listen') as listen_sessions + `).get(uid, uid, uid, uid, uid); + + return { + ...stats, + total_searchable_content: stats.total_messages + stats.total_transcripts + }; + } +} + +module.exports = new SearchService(); diff --git a/src/index.js b/src/index.js index e5ec966b..8ef624d5 100644 --- a/src/index.js +++ b/src/index.js @@ -321,6 +321,7 @@ function setupWebDataHandlers() { const askRepository = require('./features/ask/repositories'); const userRepository = require('./features/common/repositories/user'); const presetRepository = require('./features/common/repositories/preset'); + const searchService = require('./features/search/searchService'); const handleRequest = async (channel, responseChannel, payload) => { let result; @@ -404,6 +405,15 @@ function setupWebDataHandlers() { settingsService.notifyPresetUpdate('deleted', payload); break; + // SEARCH + case 'search-content': + const currentUserId = authService.getCurrentUserId(); + if (!currentUserId) { + throw new Error('User not authenticated'); + } + result = await searchService.searchSessions(payload.query, currentUserId); + break; + // BATCH case 'get-batch-data': const includes = payload ? payload.split(',').map(item => item.trim()) : ['profile', 'presets', 'sessions']; From 6ea4ea6ca85db93d799f669cbc475b093c71b003 Mon Sep 17 00:00:00 2001 From: DebjyotiRay Date: Fri, 18 Jul 2025 18:21:53 +0530 Subject: [PATCH 2/2] removed the dummy code for search --- .../backend_node/routes/conversations.js | 7 +------ src/bridge/featureBridge.js | 13 ++++++++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/pickleglass_web/backend_node/routes/conversations.js b/pickleglass_web/backend_node/routes/conversations.js index 3fbbd20c..01459e18 100644 --- a/pickleglass_web/backend_node/routes/conversations.js +++ b/pickleglass_web/backend_node/routes/conversations.js @@ -60,10 +60,5 @@ router.delete('/:session_id', async (req, res) => { } }); -// The search functionality will be more complex to move to IPC. -// For now, we can disable it or leave it as is, knowing it's a future task. -router.get('/search', (req, res) => { - res.status(501).json({ error: 'Search not implemented for IPC bridge yet.' }); -}); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js index 4527cedc..b7b6587f 100644 --- a/src/bridge/featureBridge.js +++ b/src/bridge/featureBridge.js @@ -12,6 +12,7 @@ const askService = require('../features/ask/askService'); const listenService = require('../features/listen/listenService'); const permissionService = require('../features/common/services/permissionService'); const encryptionService = require('../features/common/services/encryptionService'); +const searchService = require('../features/search/searchService'); module.exports = { // Renderer로부터의 요청을 수신하고 서비스로 전달 @@ -108,6 +109,16 @@ module.exports = { } }); + // Search + ipcMain.handle('search-content', async (event, { query }) => { + const authService = require('../features/common/services/authService'); + const uid = authService.getCurrentUserId(); + if (!uid) { + throw new Error('User not authenticated'); + } + return await searchService.searchSessions(query, uid); + }); + // ModelStateService ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key)); ipcMain.handle('model:get-all-keys', async () => await modelStateService.getAllApiKeys()); @@ -236,4 +247,4 @@ module.exports = { sendAskProgress(win, progress) { win.webContents.send('feature:ask:progress', progress); }, -}; \ No newline at end of file +};