Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions pickleglass_web/backend_node/routes/conversations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -45,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;
module.exports = router;
99 changes: 87 additions & 12 deletions pickleglass_web/components/SearchPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,7 +13,7 @@ interface SearchPopupProps {

export default function SearchPopup({ isOpen, onClose }: SearchPopupProps) {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<Session[]>([])
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [isLoading, setIsLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
Expand Down Expand Up @@ -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, '<mark class="bg-yellow-200 px-1 rounded">$1</mark>')
}

if (!isOpen) return null

return (
Expand Down Expand Up @@ -112,25 +119,93 @@ export default function SearchPopup({ isOpen, onClose }: SearchPopupProps) {
<div className="divide-y divide-gray-100">
{searchResults.map((result) => {
const timestamp = new Date(result.started_at * 1000).toLocaleString()
const sessionTypeIcon = result.session_type === 'listen' ? Mic : MessageSquare
const SessionIcon = sessionTypeIcon

return (
<div
key={result.id}
className="p-3 hover:bg-gray-50 cursor-pointer transition-colors"
className="p-4 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/activity/${result.id}`)
router.push(`/activity/details?sessionId=${result.id}`)
onClose()
}}
>
<div className="flex items-start gap-3">
<MessageSquare className="h-5 w-5 text-gray-400 mt-0.5 shrink-0" />
<SessionIcon className="h-5 w-5 text-gray-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900 mb-1 truncate">
{result.title || 'Untitled Conversation'}
</h3>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-gray-500">{timestamp}</span>
<div className="flex items-start justify-between mb-2">
<h3 className="text-sm font-medium text-gray-900 truncate flex-1">
{result.title || 'Untitled Conversation'}
</h3>
<div className="flex items-center gap-2 ml-2 shrink-0">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
result.session_type === 'listen'
? 'bg-blue-100 text-blue-800'
: 'bg-green-100 text-green-800'
}`}>
{result.session_type}
</span>
</div>
</div>

{/* Match information */}
<div className="flex items-center gap-4 mb-2 text-xs text-gray-500">
<span>{timestamp}</span>
{result.total_matches > 0 && (
<div className="flex items-center gap-3">
{result.message_matches > 0 && (
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{result.message_matches} message{result.message_matches !== 1 ? 's' : ''}
</span>
)}
{result.transcript_matches > 0 && (
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{result.transcript_matches} transcript{result.transcript_matches !== 1 ? 's' : ''}
</span>
)}
</div>
)}
</div>

{/* Content previews */}
{result.previews && result.previews.length > 0 && (
<div className="space-y-2">
{result.previews.slice(0, 2).map((preview, index) => (
<div key={index} className="bg-gray-50 rounded p-2 text-xs">
<div className="flex items-center gap-2 mb-1">
{preview.type === 'ai_message' ? (
<MessageSquare className="h-3 w-3 text-gray-400" />
) : (
<Mic className="h-3 w-3 text-gray-400" />
)}
<span className="text-gray-600 font-medium">
{preview.type === 'ai_message'
? (preview.role === 'user' ? 'You' : 'AI')
: (preview.role || 'Speaker')
}
</span>
</div>
<p className="text-gray-700 text-ellipsis overflow-hidden" style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical'
}}
dangerouslySetInnerHTML={{
__html: highlightSearchTerm(preview.snippet, searchQuery)
}}>
</p>
</div>
))}
{result.previews.length > 2 && (
<p className="text-xs text-gray-500 italic">
+{result.previews.length - 2} more match{result.previews.length - 2 !== 1 ? 'es' : ''}
</p>
)}
</div>
)}
</div>
</div>
</div>
Expand All @@ -148,4 +223,4 @@ export default function SearchPopup({ isOpen, onClose }: SearchPopupProps) {
</div>
</div>
)
}
}
38 changes: 36 additions & 2 deletions pickleglass_web/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -299,7 +321,7 @@ export const apiCall = async (path: string, options: RequestInit = {}) => {
};


export const searchConversations = async (query: string): Promise<Session[]> => {
export const searchConversations = async (query: string): Promise<SearchResult[]> => {
if (!query.trim()) {
return [];
}
Expand All @@ -308,7 +330,19 @@ export const searchConversations = async (query: string): Promise<Session[]> =>
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',
Expand Down
13 changes: 12 additions & 1 deletion src/bridge/featureBridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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로부터의 요청을 수신하고 서비스로 전달
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -236,4 +247,4 @@ module.exports = {
sendAskProgress(win, progress) {
win.webContents.send('feature:ask:progress', progress);
},
};
};
Loading