Unverified 提交 5acadeff authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Web search (#2099)

<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduces real-time web search in chat with streaming results and a richer UI. > > - Adds `web_search` tool (`tools/web_search.ts`) with zod schema, consent prompt, SSE parsing, and streaming via `onXmlStream`/`onXmlComplete` (requires Dyad Pro API key; respects `DYAD_ENGINE_URL`) > - Registers tool in `TOOL_DEFINITIONS` > - Updates markdown parser to pass `query` and `state` into `dyad-web-search` > - Enhances `DyadWebSearch` component: expandable/collapsible card, loading spinner while `pending`, keyboard/ARIA support, and displays query preview + results > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 53c9c7b65eb8dee07d8320837b39d72bf5b42b92. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds web search to chat with live streaming results from Dyad Engine. The Web Search card shows the query, a loading state, and expand/collapse for details. - **New Features** - New web_search tool that streams SSE results and writes partial updates via onXmlStream, final via onXmlComplete. - Registered tool in TOOL_DEFINITIONS with consent and zod schema. - DyadWebSearch UI: accepts query/state, shows spinner when pending, and toggles preview/details. - Markdown parser now passes query and state to DyadWebSearch. - **Migration** - Requires Dyad Pro API key in settings (providerSettings.auto.apiKey). - Optional: set DYAD_ENGINE_URL; defaults to https://engine.dyad.sh/v1. <sup>Written for commit 53c9c7b65eb8dee07d8320837b39d72bf5b42b92. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 1e92840b
......@@ -341,7 +341,10 @@ function renderCustomTag(
return (
<DyadWebSearch
node={{
properties: {},
properties: {
query: attributes.query || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
......
import type React from "react";
import type { ReactNode } from "react";
import { Globe } from "lucide-react";
import { useState, type ReactNode } from "react";
import { ChevronDown, ChevronUp, Globe, Loader } from "lucide-react";
import { CustomTagState } from "./stateTypes";
interface DyadWebSearchProps {
children?: ReactNode;
node?: any;
query?: string;
}
export const DyadWebSearch: React.FC<DyadWebSearchProps> = ({
children,
node: _node,
query: queryProp,
node,
}) => {
const query = queryProp || (typeof children === "string" ? children : "");
const [isExpanded, setIsExpanded] = useState(false);
const query =
node?.properties?.query || (typeof children === "string" ? children : "");
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border my-2">
<div
className={`bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress ? "border-blue-500" : "border-border"
}`}
onClick={() => setIsExpanded(!isExpanded)}
role="button"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe size={16} className="text-blue-600" />
<div className="text-xs text-blue-600 font-medium">Web Search</div>
{inProgress && (
<div className="flex items-center text-blue-600 text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Searching...</span>
</div>
)}
</div>
<div className="p-1 text-gray-500">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</div>
{/* Collapsed preview - show query */}
<div
className="text-sm italic text-gray-600 dark:text-gray-300 mt-2 overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "0px" : "3em",
opacity: isExpanded ? 0 : 1,
}}
>
{query}
</div>
{/* Expanded content */}
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "none" : "0px",
opacity: isExpanded ? 1 : 0,
marginTop: isExpanded ? "0.5rem" : "0",
}}
>
<div className="text-sm text-gray-600 dark:text-gray-300 space-y-2">
{query && (
<div>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
Query:
</span>
<div className="italic mt-0.5">{query}</div>
</div>
)}
{children && (
<div>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
Results:
</span>
<div className="mt-0.5">{children}</div>
</div>
)}
</div>
<div className="text-sm italic text-gray-600 dark:text-gray-300 mt-2">
{query || children}
</div>
</div>
);
......
......@@ -18,6 +18,7 @@ import { getDatabaseSchemaTool } from "./tools/get_database_schema";
import { setChatSummaryTool } from "./tools/set_chat_summary";
import { addIntegrationTool } from "./tools/add_integration";
import { editFileTool } from "./tools/edit_file";
import { webSearchTool } from "./tools/web_search";
import {
escapeXmlAttr,
escapeXmlContent,
......@@ -41,6 +42,7 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
getDatabaseSchemaTool,
setChatSummaryTool,
addIntegrationTool,
webSearchTool,
];
// ============================================================================
// Agent Tool Name Type (derived from TOOL_DEFINITIONS)
......
import { z } from "zod";
import log from "electron-log";
import {
ToolDefinition,
AgentContext,
escapeXmlAttr,
escapeXmlContent,
} from "./types";
import { readSettings } from "@/main/settings";
const logger = log.scope("web_search");
const DYAD_ENGINE_URL =
process.env.DYAD_ENGINE_URL ?? "https://engine.dyad.sh/v1";
const webSearchSchema = z.object({
query: z.string().describe("The search query to look up on the web"),
});
const DESCRIPTION = `
Use this tool to access real-time information beyond your training data cutoff.
When to Search:
- Current API documentation, library versions, or breaking changes
- Latest best practices, security advisories, or bug fixes
- Specific error messages or troubleshooting solutions
- Recent framework updates or deprecation notices
Query Tips:
- Be specific: Include version numbers, exact error messages, or technical terms
- Add context: "React 19 useEffect cleanup" not just "React hooks"
Examples:
<example>
OpenAI GPT-5 API model names
</example>
<example>
NextJS 14 app router middleware auth
</example>
`;
/**
* Parse SSE events from a buffer and extract content deltas.
* Returns the remaining unparsed buffer.
* Throws an error if an SSE error event is received.
*/
function parseSSEEvents(
buffer: string,
onContent: (content: string) => void,
): string {
const lines = buffer.split("\n");
// Keep the last potentially incomplete line
const remaining = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data: ")) {
continue;
}
const data = trimmed.slice(6); // Remove "data: " prefix
// Check for stream end marker
if (data === "[DONE]") {
continue;
}
try {
const json = JSON.parse(data);
// Check for OpenAI-style SSE error: { error: { message: "...", type: "...", code: "..." } }
if (json.error) {
const errorMessage =
json.error.message || json.error.type || "Unknown SSE error";
throw new Error(`Web search SSE error: ${errorMessage}`);
}
// OpenAI-style SSE format: { choices: [{ delta: { content: "..." } }] }
const content = json.choices?.[0]?.delta?.content;
if (content) {
onContent(content);
}
} catch (e) {
// Re-throw SSE errors
if (e instanceof Error && e.message.startsWith("Web search SSE error:")) {
throw e;
}
// Skip malformed JSON lines
logger.warn("Failed to parse SSE JSON:", data);
}
}
return remaining;
}
/**
* Call the web search SSE endpoint and stream results
*/
async function callWebSearchSSE(
query: string,
ctx: AgentContext,
): Promise<string> {
const settings = readSettings();
const apiKey = settings.providerSettings?.auto?.apiKey?.value;
if (!apiKey) {
throw new Error("Dyad Pro API key is required for web_search tool");
}
ctx.onXmlStream(`<dyad-web-search query="${escapeXmlAttr(query)}">`);
const response = await fetch(`${DYAD_ENGINE_URL}/tools/web-search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
Accept: "text/event-stream",
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Web search failed: ${response.status} ${response.statusText} - ${errorText}`,
);
}
if (!response.body) {
throw new Error("Web search response has no body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulated = "";
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events and accumulate content
buffer = parseSSEEvents(buffer, (content) => {
accumulated += content;
// Stream intermediate results to UI with dyad-web-search prefix
ctx.onXmlStream(
`<dyad-web-search query="${escapeXmlAttr(query)}">${escapeXmlContent(accumulated)}`,
);
});
}
// Handle any remaining buffer content
if (buffer.trim()) {
parseSSEEvents(buffer + "\n", (content) => {
accumulated += content;
});
}
} finally {
reader.releaseLock();
}
return accumulated;
}
export const webSearchTool: ToolDefinition<z.infer<typeof webSearchSchema>> = {
name: "web_search",
description: DESCRIPTION,
inputSchema: webSearchSchema,
defaultConsent: "ask",
getConsentPreview: (args) => `Search the web: "${args.query}"`,
execute: async (args, ctx: AgentContext) => {
logger.log(`Executing web search: ${args.query}`);
const result = await callWebSearchSSE(args.query, ctx);
if (!result) {
throw new Error("Web search returned no results");
}
// Write final result to UI and DB with dyad-web-search wrapper
ctx.onXmlComplete(
`<dyad-web-search query="${escapeXmlAttr(args.query)}">${escapeXmlContent(result)}</dyad-web-search>`,
);
logger.log(`Web search completed for query: ${args.query}`);
return result;
},
};
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论