Skip to content

Commit 33668a2

Browse files
authored
feat: option web search tool (#129)
Adds the optional OpenAI web search tool. Closes #125 ![CleanShot 2025-07-10 at 17 36 43](https://github.com/user-attachments/assets/6dc6d80c-0963-4496-85c2-b703f08fd584)
1 parent 31863c3 commit 33668a2

19 files changed

+957
-25
lines changed

src/components/BotMessage.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,14 @@ export function BotMessage({ message, fileAnnotations = [] }: BotMessageProps) {
4141
const processedContent = message.content
4242

4343
// Separate image and non-image files
44-
const imageFiles = fileAnnotations.filter((annotation) =>
44+
// Only treat annotations with type 'file' or 'image' as downloadable
45+
const fileLikeAnnotations = fileAnnotations.filter(
46+
(annotation) => annotation.type === 'file' || annotation.type === 'image',
47+
)
48+
const imageFiles = fileLikeAnnotations.filter((annotation) =>
4549
isImageFile(annotation.filename),
4650
)
47-
const otherFiles = fileAnnotations.filter(
51+
const otherFiles = fileLikeAnnotations.filter(
4852
(annotation) => !isImageFile(annotation.filename),
4953
)
5054

src/components/Chat.tsx

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useUser } from '../contexts/UserContext'
1515
import { Button } from './ui/button'
1616
import { MessageSquarePlus } from 'lucide-react'
1717
import { CodeInterpreterToggle } from './CodeInterpreterToggle'
18+
import { WebSearchToggle } from './WebSearchToggle'
1819
import { ModelSelect } from './ModelSelect'
1920
import { BotThinking } from './BotThinking'
2021
import { BotError } from './BotError'
@@ -23,6 +24,7 @@ import { CodeInterpreterMessage } from './CodeInterpreterMessage'
2324
import type { AnnotatedFile } from '@/lib/utils/code-interpreter'
2425
import { isCodeInterpreterSupported } from '@/lib/utils/prompting'
2526
import { useHasMounted } from '@/hooks/useHasMounted'
27+
import { WebSearchMessage } from './WebSearchMessage'
2628

2729
type StreamEvent =
2830
| {
@@ -103,6 +105,15 @@ type StreamEvent =
103105
message: string
104106
details?: unknown
105107
}
108+
| {
109+
type: 'web_search'
110+
id: string
111+
status: 'in_progress' | 'searching' | 'completed' | 'failed' | 'result'
112+
query?: string
113+
results?: Array<{ title: string; url: string; snippet?: string }>
114+
error?: string
115+
raw?: unknown
116+
}
106117

107118
const getToolStatus = (
108119
toolType: string,
@@ -149,6 +160,9 @@ const getEventKey = (event: StreamEvent | Message, idx: number): string => {
149160
// Use file_id or a combination for uniqueness
150161
return `file-annotation-${event.annotation.file_id || idx}`
151162
}
163+
if (event.type === 'web_search') {
164+
return `web-search-${event.id}`
165+
}
152166
}
153167
// Fallback: use idx if id is not present
154168
return `message-${'id' in event ? (event as any).id : idx}`
@@ -164,6 +178,7 @@ export function Chat() {
164178
const [streamBuffer, setStreamBuffer] = useState<StreamEvent[]>([])
165179
const [streaming, setStreaming] = useState(false)
166180
const [useCodeInterpreter, setUseCodeInterpreter] = useState(false)
181+
const [useWebSearch, setUseWebSearch] = useState(false)
167182
const { selectedModel, setSelectedModel } = useModel()
168183
const { user } = useUser()
169184

@@ -283,8 +298,16 @@ export function Chat() {
283298
model: selectedModel,
284299
userId: user?.id,
285300
codeInterpreter: useCodeInterpreter,
301+
webSearch: useWebSearch,
286302
}),
287-
[selectedServers, servers, selectedModel, user?.id, useCodeInterpreter],
303+
[
304+
selectedServers,
305+
servers,
306+
selectedModel,
307+
user?.id,
308+
useCodeInterpreter,
309+
useWebSearch,
310+
],
288311
)
289312

290313
const handleError = useCallback((error: Error) => {
@@ -575,6 +598,52 @@ export function Chat() {
575598
return
576599
}
577600

601+
// --- Web Search streaming events ---
602+
if (
603+
toolState.type &&
604+
toolState.type.startsWith('response.web_search_call.')
605+
) {
606+
// Map event type to status
607+
let status:
608+
| 'in_progress'
609+
| 'searching'
610+
| 'completed'
611+
| 'failed'
612+
| 'result' = 'in_progress'
613+
if (toolState.type.endsWith('in_progress')) status = 'in_progress'
614+
else if (toolState.type.endsWith('searching'))
615+
status = 'searching'
616+
else if (toolState.type.endsWith('completed'))
617+
status = 'completed'
618+
else if (toolState.type.endsWith('failed')) status = 'failed'
619+
else if (toolState.type.endsWith('result')) status = 'result'
620+
setStreamBuffer((prev: StreamEvent[]) => {
621+
const existingIdx = prev.findIndex(
622+
(e) => e.type === 'web_search' && e.id === toolState.item_id,
623+
)
624+
const newEvent: Extract<StreamEvent, { type: 'web_search' }> = {
625+
type: 'web_search',
626+
id: toolState.item_id,
627+
status,
628+
query: toolState.query,
629+
error: toolState.error,
630+
raw: toolState,
631+
}
632+
if (existingIdx !== -1) {
633+
// Replace the old event
634+
return [
635+
...prev.slice(0, existingIdx),
636+
newEvent,
637+
...prev.slice(existingIdx + 1),
638+
]
639+
} else {
640+
// Add new event
641+
return [...prev, newEvent]
642+
}
643+
})
644+
return
645+
}
646+
578647
if ('delta' in toolState) {
579648
try {
580649
toolState.delta =
@@ -816,6 +885,7 @@ export function Chat() {
816885
setInput('')
817886
setFocusTimestamp(Date.now())
818887
setUseCodeInterpreter(false)
888+
setUseWebSearch(false)
819889

820890
textBufferRef.current = ''
821891
lastAssistantIdRef.current = null
@@ -937,6 +1007,8 @@ export function Chat() {
9371007
}}
9381008
/>
9391009
)
1010+
} else if ('type' in event && event.type === 'web_search') {
1011+
return <WebSearchMessage key={key} event={event} />
9401012
} else {
9411013
// Fallback for Message type (from useChat)
9421014
const message = event as Message
@@ -1024,6 +1096,12 @@ export function Chat() {
10241096
selectedModel={selectedModel}
10251097
disabled={hasStartedChat}
10261098
/>
1099+
<WebSearchToggle
1100+
useWebSearch={useWebSearch}
1101+
onToggle={setUseWebSearch}
1102+
selectedModel={selectedModel}
1103+
disabled={hasStartedChat}
1104+
/>
10271105
</ServerSelector>
10281106
</div>
10291107
)}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Meta, StoryObj } from '@storybook/react'
2+
import { CodeInterpreterToggle } from './CodeInterpreterToggle'
3+
import { useState } from 'react'
4+
5+
const meta: Meta<typeof CodeInterpreterToggle> = {
6+
title: 'UI/CodeInterpreterToggle',
7+
component: CodeInterpreterToggle,
8+
tags: ['autodocs'],
9+
argTypes: {
10+
selectedModel: {
11+
control: 'text',
12+
defaultValue: 'gpt-4',
13+
},
14+
disabled: {
15+
control: 'boolean',
16+
defaultValue: false,
17+
},
18+
},
19+
}
20+
export default meta
21+
22+
type Story = StoryObj<typeof CodeInterpreterToggle>
23+
24+
const Template = (args: any) => {
25+
const [enabled, setEnabled] = useState(args.useCodeInterpreter ?? false)
26+
return (
27+
<CodeInterpreterToggle
28+
{...args}
29+
useCodeInterpreter={enabled}
30+
onToggle={setEnabled}
31+
/>
32+
)
33+
}
34+
35+
export const Default: Story = {
36+
render: Template,
37+
args: {
38+
useCodeInterpreter: false,
39+
selectedModel: 'gpt-4',
40+
disabled: false,
41+
},
42+
}
43+
44+
export const Enabled: Story = {
45+
render: Template,
46+
args: {
47+
useCodeInterpreter: true,
48+
selectedModel: 'gpt-4',
49+
disabled: false,
50+
},
51+
}
52+
53+
export const Disabled: Story = {
54+
render: Template,
55+
args: {
56+
useCodeInterpreter: false,
57+
selectedModel: 'gpt-4',
58+
disabled: true,
59+
},
60+
}
61+
62+
export const UnsupportedModel: Story = {
63+
render: Template,
64+
args: {
65+
useCodeInterpreter: false,
66+
selectedModel: 'unsupported-model',
67+
disabled: false,
68+
},
69+
}
Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Toggle } from './ui/toggle'
21
import { Code2 } from 'lucide-react'
32
import { isCodeInterpreterSupported } from '@/lib/utils/prompting'
3+
import { ToolToggle } from './ToolToggle'
44

55
interface CodeInterpreterToggleProps {
66
useCodeInterpreter: boolean
@@ -16,18 +16,16 @@ export function CodeInterpreterToggle({
1616
disabled = false,
1717
}: CodeInterpreterToggleProps) {
1818
const isSupported = isCodeInterpreterSupported(selectedModel)
19-
const isDisabled = disabled || !isSupported
2019

2120
return (
22-
<Toggle
21+
<ToolToggle
2322
isSelected={useCodeInterpreter}
24-
onToggle={() => onToggle(!useCodeInterpreter)}
25-
disabled={isDisabled}
26-
className={!isSupported ? 'opacity-50' : ''}
27-
title={`Execute Python code for calculations, data analysis, and visualizations.${!isSupported ? ` Not supported by ${selectedModel}` : ''}`}
28-
>
29-
<Code2 className="h-4 w-4" />
30-
<span className="sr-only text-sm md:not-sr-only">Code Interpreter</span>
31-
</Toggle>
23+
onToggle={onToggle}
24+
isSupported={isSupported}
25+
icon={<Code2 className="h-4 w-4" />}
26+
label="Code Interpreter"
27+
tooltip={`Execute Python code for calculations, data analysis, and visualizations.${!isSupported ? ` Not supported by ${selectedModel}` : ''}`}
28+
disabled={!isSupported || disabled}
29+
/>
3230
)
3331
}

src/components/ToolToggle.stories.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { Meta, StoryObj } from '@storybook/react'
2+
import { ToolToggle } from './ToolToggle'
3+
import { useState } from 'react'
4+
5+
const meta: Meta<typeof ToolToggle> = {
6+
title: 'UI/ToolToggle',
7+
component: ToolToggle,
8+
tags: ['autodocs'],
9+
argTypes: {
10+
label: {
11+
control: 'text',
12+
defaultValue: 'Tool',
13+
},
14+
tooltip: {
15+
control: 'text',
16+
defaultValue: 'Toggle this tool',
17+
},
18+
disabled: {
19+
control: 'boolean',
20+
defaultValue: false,
21+
},
22+
isSupported: {
23+
control: 'boolean',
24+
defaultValue: true,
25+
},
26+
},
27+
}
28+
export default meta
29+
30+
type Story = StoryObj<typeof ToolToggle>
31+
32+
const Template = (args: any) => {
33+
const [selected, setSelected] = useState(args.isSelected ?? false)
34+
return (
35+
<ToolToggle
36+
{...args}
37+
isSelected={selected}
38+
onToggle={setSelected}
39+
icon={
40+
<span role="img" aria-label="tool">
41+
🛠️
42+
</span>
43+
}
44+
/>
45+
)
46+
}
47+
48+
export const Default: Story = {
49+
render: Template,
50+
args: {
51+
isSelected: false,
52+
isSupported: true,
53+
label: 'Tool',
54+
tooltip: 'Toggle this tool',
55+
disabled: false,
56+
},
57+
}
58+
59+
export const Enabled: Story = {
60+
render: Template,
61+
args: {
62+
isSelected: true,
63+
isSupported: true,
64+
label: 'Tool',
65+
tooltip: 'Toggle this tool',
66+
disabled: false,
67+
},
68+
}
69+
70+
export const Disabled: Story = {
71+
render: Template,
72+
args: {
73+
isSelected: false,
74+
isSupported: true,
75+
label: 'Tool',
76+
tooltip: 'Toggle this tool',
77+
disabled: true,
78+
},
79+
}
80+
81+
export const Unsupported: Story = {
82+
render: Template,
83+
args: {
84+
isSelected: false,
85+
isSupported: false,
86+
label: 'Tool',
87+
tooltip: 'This tool is not supported',
88+
disabled: false,
89+
},
90+
}

0 commit comments

Comments
 (0)