Skip to content

Commit f454f5f

Browse files
committed
fix (chat): changed stt provider
feat (search): added search functionality
1 parent 791487a commit f454f5f

14 files changed

Lines changed: 615 additions & 244 deletions

File tree

src/client/app/api/search/route.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { NextResponse } from "next/server"
2+
import { withAuth } from "@lib/api-utils"
3+
4+
const appServerUrl =
5+
process.env.NEXT_PUBLIC_ENVIRONMENT === "selfhost"
6+
? process.env.INTERNAL_APP_SERVER_URL
7+
: process.env.NEXT_PUBLIC_APP_SERVER_URL
8+
9+
export const GET = withAuth(async function GET(request, { authHeader }) {
10+
const { searchParams } = new URL(request.url)
11+
const query = searchParams.get("q")
12+
13+
if (!query) {
14+
return NextResponse.json(
15+
{ error: "Query parameter 'q' is required" },
16+
{ status: 400 }
17+
)
18+
}
19+
20+
const backendUrl = new URL(`${appServerUrl}/api/search/interactive`)
21+
backendUrl.searchParams.append("query", query)
22+
23+
try {
24+
const response = await fetch(backendUrl.toString(), {
25+
method: "GET",
26+
headers: { "Content-Type": "application/json", ...authHeader }
27+
})
28+
29+
const data = await response.json()
30+
if (!response.ok) {
31+
throw new Error(data.detail || "Failed to perform search")
32+
}
33+
return NextResponse.json(data)
34+
} catch (error) {
35+
console.error("API Error in /api/search:", error)
36+
return NextResponse.json({ error: error.message }, { status: 500 })
37+
}
38+
})

src/client/app/chat/page.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
IconFileText,
1111
IconArrowBackUp,
1212
IconX,
13-
IconMenu2,
13+
IconDotsVertical,
1414
IconPhone,
1515
IconPhoneOff,
1616
IconWaveSine,
@@ -1276,7 +1276,7 @@ export default function ChatPage() {
12761276
}}
12771277
className="p-2 rounded-full bg-neutral-800/50 hover:bg-neutral-700/80 text-white"
12781278
>
1279-
<IconMenu2 size={20} />
1279+
<IconDotsVertical size={20} />
12801280
</button>
12811281
<AnimatePresence>
12821282
{isOptionsOpen && (

src/client/components/ChatBubble.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ const ToolCodeBlock = ({ name, code, isExpanded, onToggle }) => {
146146
<div className="mb-4 border-l-2 border-green-500 pl-3">
147147
<button
148148
onClick={onToggle}
149-
className="flex items-center gap-2 text-green-400 hover:text-green-300 text-sm font-semibold"
149+
className="flex w-full items-center justify-start gap-2 text-green-400 hover:text-green-300 text-sm font-semibold"
150150
data-tooltip-id="chat-bubble-tooltip"
151151
data-tooltip-content="Click to see the tool call details."
152152
>
@@ -182,7 +182,7 @@ const ToolResultBlock = ({ name, result, isExpanded, onToggle }) => {
182182
<div className="mb-4 border-l-2 border-purple-500 pl-3">
183183
<button
184184
onClick={onToggle}
185-
className="flex items-center gap-2 text-purple-400 hover:text-purple-300 text-sm font-semibold"
185+
className="flex w-full items-center justify-start gap-2 text-purple-400 hover:text-purple-300 text-sm font-semibold"
186186
data-tooltip-id="chat-bubble-tooltip"
187187
data-tooltip-content="Click to see the result from the tool."
188188
>
@@ -299,7 +299,9 @@ const ChatBubble = ({
299299

300300
// Extract final answer by removing all other tags.
301301
// The <answer> tag takes precedence.
302-
const answerMatch = contentString.match(/<answer>([\s\S]*?)<\/answer>/)
302+
const answerMatch = contentString.match(
303+
/<answer>([\s\S]*?)<\/answer>/
304+
)
303305
let final
304306
if (answerMatch) {
305307
final = answerMatch[1]
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"use client"
2+
3+
import React, { useState, useEffect, useCallback, useMemo } from "react"
4+
import { motion, AnimatePresence } from "framer-motion"
5+
import {
6+
IconSearch,
7+
IconX,
8+
IconLoader,
9+
IconChecklist,
10+
IconMessage,
11+
IconBrain,
12+
IconFileText,
13+
IconArrowRight
14+
} from "@tabler/icons-react"
15+
import { cn } from "@utils/cn"
16+
import { useRouter } from "next/navigation"
17+
import { formatDistanceToNow, parseISO } from "date-fns"
18+
19+
const useDebounce = (value, delay) => {
20+
const [debouncedValue, setDebouncedValue] = useState(value)
21+
useEffect(() => {
22+
const handler = setTimeout(() => {
23+
setDebouncedValue(value)
24+
}, delay)
25+
return () => {
26+
clearTimeout(handler)
27+
}
28+
}, [value, delay])
29+
return debouncedValue
30+
}
31+
32+
const ResultItem = ({ item }) => {
33+
const router = useRouter()
34+
const handleClick = () => {
35+
if (item.type === "task") {
36+
router.push(`/tasks?taskId=${item.task_id}`)
37+
} else if (item.type === "chat") {
38+
router.push(`/chat`) // Future enhancement: scroll to message
39+
} else if (item.type === "memory") {
40+
router.push(`/memories`) // Future enhancement: open memory detail
41+
}
42+
}
43+
44+
const icons = {
45+
task: <IconChecklist size={18} className="text-blue-400" />,
46+
chat: <IconMessage size={18} className="text-green-400" />,
47+
memory: <IconBrain size={18} className="text-yellow-400" />
48+
}
49+
50+
const title =
51+
item.type === "task"
52+
? item.name
53+
: item.type === "chat"
54+
? item.content
55+
: item.content
56+
57+
const timestamp = item.timestamp || item.created_at
58+
59+
return (
60+
<button
61+
onClick={handleClick}
62+
className="w-full text-left p-3 rounded-lg hover:bg-neutral-700/50 transition-colors flex items-center gap-4"
63+
>
64+
<div className="flex-shrink-0">{icons[item.type]}</div>
65+
<div className="flex-1 overflow-hidden">
66+
<p className="text-sm text-neutral-100 truncate font-medium">
67+
{title}
68+
</p>
69+
<p className="text-xs text-neutral-400 mt-1">
70+
{item.type.charAt(0).toUpperCase() + item.type.slice(1)}
71+
{timestamp &&
72+
` • ${formatDistanceToNow(parseISO(timestamp), { addSuffix: true })}`}
73+
</p>
74+
</div>
75+
<IconArrowRight
76+
size={16}
77+
className="text-neutral-500 flex-shrink-0"
78+
/>
79+
</button>
80+
)
81+
}
82+
83+
export default function GlobalSearch({ onClose }) {
84+
const [query, setQuery] = useState("")
85+
const [activeFilter, setActiveFilter] = useState("All")
86+
const [results, setResults] = useState([])
87+
const [isLoading, setIsLoading] = useState(false)
88+
const debouncedQuery = useDebounce(query, 300)
89+
90+
const filters = ["All", "Tasks", "Chats", "Memories"]
91+
92+
const fetchResults = useCallback(async (searchQuery) => {
93+
if (searchQuery.length < 3) {
94+
setResults([])
95+
return
96+
}
97+
setIsLoading(true)
98+
try {
99+
const response = await fetch(
100+
`/api/search?q=${encodeURIComponent(searchQuery)}`
101+
)
102+
if (!response.ok) {
103+
throw new Error("Search failed")
104+
}
105+
const data = await response.json()
106+
setResults(data.results || [])
107+
} catch (error) {
108+
console.error("Search error:", error)
109+
setResults([])
110+
} finally {
111+
setIsLoading(false)
112+
}
113+
}, [])
114+
115+
useEffect(() => {
116+
fetchResults(debouncedQuery)
117+
}, [debouncedQuery, fetchResults])
118+
119+
const filteredResults = useMemo(() => {
120+
if (activeFilter === "All") {
121+
return results
122+
}
123+
const filterType = activeFilter.slice(0, -1).toLowerCase() // Tasks -> task
124+
return results.filter((r) => r.type === filterType)
125+
}, [results, activeFilter])
126+
127+
return (
128+
<motion.div
129+
initial={{ opacity: 0 }}
130+
animate={{ opacity: 1 }}
131+
exit={{ opacity: 0 }}
132+
className="fixed inset-0 bg-black/70 backdrop-blur-md z-[60] flex items-start justify-center p-4 pt-[15vh] md:pt-[20vh]"
133+
onClick={onClose}
134+
>
135+
<motion.div
136+
initial={{ scale: 0.95, y: -20 }}
137+
animate={{ scale: 1, y: 0 }}
138+
exit={{ scale: 0.95, y: -20 }}
139+
transition={{ duration: 0.2, ease: "easeInOut" }}
140+
onClick={(e) => e.stopPropagation()}
141+
className="relative bg-neutral-900/80 backdrop-blur-xl p-4 rounded-2xl shadow-2xl w-full max-w-2xl border border-neutral-700 flex flex-col"
142+
>
143+
{/* Search Input */}
144+
<div className="relative flex-shrink-0">
145+
<IconSearch
146+
className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500"
147+
size={20}
148+
/>
149+
<input
150+
type="text"
151+
value={query}
152+
onChange={(e) => setQuery(e.target.value)}
153+
placeholder="Search tasks, chats, and memories..."
154+
className="w-full bg-neutral-800/50 border border-neutral-700 rounded-lg pl-12 pr-10 py-3 text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange"
155+
autoFocus
156+
/>
157+
<button
158+
onClick={onClose}
159+
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-full hover:bg-neutral-700"
160+
>
161+
<IconX size={18} />
162+
</button>
163+
</div>
164+
165+
{/* Filters */}
166+
<div className="flex flex-wrap gap-2 mt-4 px-1 flex-shrink-0">
167+
{filters.map((filter) => (
168+
<button
169+
key={filter}
170+
onClick={() => setActiveFilter(filter)}
171+
className={cn(
172+
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
173+
activeFilter === filter
174+
? "bg-brand-orange text-black"
175+
: "bg-neutral-800 text-neutral-300 hover:bg-neutral-700"
176+
)}
177+
>
178+
{filter}
179+
</button>
180+
))}
181+
</div>
182+
183+
{/* Results */}
184+
<div className="mt-4 pt-4 border-t border-neutral-800 min-h-[200px] max-h-[50vh] overflow-y-auto custom-scrollbar">
185+
<AnimatePresence mode="wait">
186+
{isLoading ? (
187+
<motion.div
188+
key="loader"
189+
initial={{ opacity: 0 }}
190+
animate={{ opacity: 1 }}
191+
exit={{ opacity: 0 }}
192+
className="flex justify-center items-center h-full p-8"
193+
>
194+
<IconLoader className="animate-spin text-neutral-500" />
195+
</motion.div>
196+
) : filteredResults.length > 0 ? (
197+
<motion.div
198+
key="results"
199+
initial={{ opacity: 0 }}
200+
animate={{ opacity: 1 }}
201+
exit={{ opacity: 0 }}
202+
className="space-y-1"
203+
>
204+
{filteredResults.map((item) => (
205+
<ResultItem
206+
key={`${item.type}-${item.task_id || item.message_id || item.id}`}
207+
item={item}
208+
/>
209+
))}
210+
</motion.div>
211+
) : (
212+
<motion.div
213+
key="empty"
214+
initial={{ opacity: 0 }}
215+
animate={{ opacity: 1 }}
216+
exit={{ opacity: 0 }}
217+
className="flex flex-col justify-center items-center text-center h-full p-8 text-neutral-500"
218+
>
219+
<IconFileText size={32} className="mb-2" />
220+
<p className="font-medium text-neutral-400">
221+
{debouncedQuery.length < 3
222+
? "Start typing to search"
223+
: `No results for "${debouncedQuery}"`}
224+
</p>
225+
<p className="text-sm">
226+
{debouncedQuery.length < 3
227+
? "Search requires at least 3 characters."
228+
: "Try a different search term."}
229+
</p>
230+
</motion.div>
231+
)}
232+
</AnimatePresence>
233+
</div>
234+
</motion.div>
235+
</motion.div>
236+
)
237+
}

src/client/components/LayoutWrapper.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import React, { useState, useEffect, useCallback, useRef } from "react"
33
import { usePathname, useRouter } from "next/navigation"
44
import { AnimatePresence } from "framer-motion"
55
import NotificationsOverlay from "@components/NotificationsOverlay"
6-
import { IconBell, IconMenu2, IconLoader } from "@tabler/icons-react"
6+
import { IconMenu2, IconLoader } from "@tabler/icons-react"
77
import Sidebar from "@components/Sidebar"
8-
import CommandPalette from "./CommandPallete" // Corrected import path
8+
import CommandPalette from "./CommandPallete"
9+
import GlobalSearch from "./GlobalSearch"
910
import { useGlobalShortcuts } from "@hooks/useGlobalShortcuts"
1011
import { cn } from "@utils/cn"
1112
import toast from "react-hot-toast"
1213

1314
export default function LayoutWrapper({ children }) {
1415
const [isNotificationsOpen, setNotificationsOpen] = useState(false)
16+
const [isSearchOpen, setSearchOpen] = useState(false)
1517
const [isCommandPaletteOpen, setCommandPaletteOpen] = useState(false)
1618
const [isSidebarCollapsed, setSidebarCollapsed] = useState(true)
1719
const [isMobileNavOpen, setMobileNavOpen] = useState(false)
@@ -256,6 +258,7 @@ export default function LayoutWrapper({ children }) {
256258
setSidebarCollapsed(!isSidebarCollapsed)
257259
}
258260
onNotificationsOpen={handleNotificationsOpen}
261+
onSearchOpen={() => setSearchOpen(true)}
259262
unreadCount={unreadCount}
260263
isMobileOpen={isMobileNavOpen}
261264
onMobileClose={() => setMobileNavOpen(false)}
@@ -290,6 +293,11 @@ export default function LayoutWrapper({ children }) {
290293
/>
291294
)}
292295
</AnimatePresence>
296+
<AnimatePresence>
297+
{isSearchOpen && (
298+
<GlobalSearch onClose={() => setSearchOpen(false)} />
299+
)}
300+
</AnimatePresence>
293301
</>
294302
)
295303
}

0 commit comments

Comments
 (0)