diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 055394ce..f65e59ea 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -352,7 +352,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 31cd0dd2..935d1ca9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1896,7 +1896,8 @@ function App() { const handlePromptSubmit = async ( message: PromptInputMessage, mentions?: FileMention[], - stagedAttachments: StagedAttachment[] = [] + stagedAttachments: StagedAttachment[] = [], + searchEnabled?: boolean, ) => { if (isProcessing) return @@ -1994,6 +1995,7 @@ function App() { message: attachmentPayload, voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, + searchEnabled: searchEnabled || undefined, }) } else { // Legacy path: plain string with optional XML-formatted @mentions. @@ -2024,6 +2026,7 @@ function App() { message: formattedMessage, voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, + searchEnabled: searchEnabled || undefined, }) titleSource = formattedMessage diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index e7db29d8..057550b2 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -10,6 +10,7 @@ import { FileSpreadsheet, FileText, FileVideo, + Globe, Headphones, LoaderIcon, Mic, @@ -56,6 +57,7 @@ export type StagedAttachment = { const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB + const providerDisplayNames: Record = { openai: 'OpenAI', anthropic: 'Anthropic', @@ -95,7 +97,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -152,6 +154,8 @@ function ChatInputInner({ const [configuredModels, setConfiguredModels] = useState([]) const [activeModelKey, setActiveModelKey] = useState('') + const [searchEnabled, setSearchEnabled] = useState(false) + const [searchAvailable, setSearchAvailable] = useState(false) // Load model config from disk (on mount and whenever tab becomes active) const loadModelConfig = useCallback(async () => { @@ -209,6 +213,27 @@ function ChatInputInner({ return () => window.removeEventListener('models-config-changed', handler) }, [loadModelConfig]) + // Check search tool availability (brave or exa) + useEffect(() => { + const checkSearch = async () => { + let available = false + try { + const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/brave-search.json' }) + const config = JSON.parse(raw.data) + if (config.apiKey) available = true + } catch { /* not configured */ } + if (!available) { + try { + const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' }) + const config = JSON.parse(raw.data) + if (config.apiKey) available = true + } catch { /* not configured */ } + } + setSearchAvailable(available) + } + checkSearch() + }, [isActive]) + const handleModelChange = useCallback(async (key: string) => { const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) if (!entry) return @@ -290,11 +315,12 @@ function ChatInputInner({ const handleSubmit = useCallback(() => { if (!canSubmit) return - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments) + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined) controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) - }, [attachments, canSubmit, controller, message, onSubmit]) + setSearchEnabled(false) + }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -446,6 +472,28 @@ function ChatInputInner({ > + {searchAvailable && ( + searchEnabled ? ( + + ) : ( + + ) + )}
{configuredModels.length > 0 && ( diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 0c15f610..f752543f 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -896,6 +896,7 @@ export async function* streamAgent({ // get any queued user messages let voiceInput = false; let voiceOutput: 'summary' | 'full' | null = null; + let searchEnabled = false; while (true) { const msg = await messageQueue.dequeue(runId); if (!msg) { @@ -904,6 +905,9 @@ export async function* streamAgent({ if (msg.voiceInput) { voiceInput = true; } + if (msg.searchEnabled) { + searchEnabled = true; + } if (msg.voiceOutput) { voiceOutput = msg.voiceOutput; } @@ -958,6 +962,10 @@ export async function* streamAgent({ loopLogger.log('voice output enabled (full mode), injecting voice output prompt'); instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY)\nThe user wants your ENTIRE response spoken aloud. You MUST wrap your full response in tags. This is NOT optional.\n\nRules:\n1. Wrap EACH sentence in its own separate tag so it can be spoken incrementally.\n2. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n3. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n4. Every sentence MUST be inside a tag. Do not leave any content outside tags.\n\nExample:\nYour meeting with Sarah covered three main things.\nFirst, you discussed the Q2 roadmap timeline and agreed to push the launch to April.\nSecond, you talked about hiring for the backend role — Sarah will send over two candidates by Friday.\nAnd lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.`; } + if (searchEnabled) { + loopLogger.log('search enabled, injecting search prompt'); + instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Load the search skill and use web search or research search as needed to answer their query.`; + } let streamError: string | null = null; for await (const event of streamLlm( model, diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts index 9dc0980d..d60b51b1 100644 --- a/apps/x/packages/core/src/application/lib/message-queue.ts +++ b/apps/x/packages/core/src/application/lib/message-queue.ts @@ -10,10 +10,11 @@ type EnqueuedMessage = { message: UserMessageContentType; voiceInput?: boolean; voiceOutput?: VoiceOutputMode; + searchEnabled?: boolean; }; export interface IMessageQueue { - enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode): Promise; + enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise; dequeue(runId: string): Promise; } @@ -29,7 +30,7 @@ export class InMemoryMessageQueue implements IMessageQueue { this.idGenerator = idGenerator; } - async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode): Promise { + async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise { if (!this.store[runId]) { this.store[runId] = []; } @@ -39,6 +40,7 @@ export class InMemoryMessageQueue implements IMessageQueue { message, voiceInput, voiceOutput, + searchEnabled, }); return id; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 7c2f3910..30c9bd67 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -19,9 +19,9 @@ export async function createRun(opts: z.infer): Promise return run; } -export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode): Promise { +export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise { const queue = container.resolve('messageQueue'); - const id = await queue.enqueue(runId, message, voiceInput, voiceOutput); + const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled); const runtime = container.resolve('agentRuntime'); runtime.trigger(runId); return id; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 97f753e1..748702f0 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -132,6 +132,7 @@ const ipcSchemas = { message: UserMessageContent, voiceInput: z.boolean().optional(), voiceOutput: z.enum(['summary', 'full']).optional(), + searchEnabled: z.boolean().optional(), }), res: z.object({ messageId: z.string(),