Skip to content

Commit 789114c

Browse files
committed
Add WIP search AI tool
1 parent c75a33f commit 789114c

6 files changed

Lines changed: 162 additions & 7 deletions

File tree

packages/shared/src/internal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from "./parseRoutePattern";
1313
export * from "./getNextAppRouterSourceFolder";
1414
export * from "./routeValidation";
1515
export * from "./getErrorMessageFromUnknownJson";
16+
export * from "./zod/SerializedSchema";

packages/ui/spa/components/ValProvider.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@ import { ValErrorProvider } from "./ValErrorProvider";
4949
import { ValPortalProvider } from "./ValPortalProvider";
5050
import { ValFieldProvider } from "./ValFieldProvider";
5151
import { ValRemoteProvider } from "./ValRemoteProvider";
52+
import { toolNames } from "../utils/toolNames";
5253

5354
export const AITool = z.object({
54-
name: z.string(),
55+
name: z.enum(toolNames),
5556
description: z.string(),
5657
parameters: z.object({
5758
type: z.literal("object"),
58-
properties: z.record(z.unknown()),
59+
properties: z.record(z.string(), z.unknown()),
5960
required: z.array(z.string()).optional(),
6061
}),
6162
});
@@ -110,7 +111,9 @@ type ValContextValue = {
110111
| "unauthorized";
111112
};
112113
subscribeToWsMessages: (handler: WsMessageHandler) => () => void;
113-
sendWsMessage: (data: z.infer<typeof AIPromptMessage> | AIToolResultMessage) => void;
114+
sendWsMessage: (
115+
data: z.infer<typeof AIPromptMessage> | AIToolResultMessage,
116+
) => void;
114117
isWsConnected: boolean;
115118
};
116119
const ValContext = React.createContext<ValContextValue>(

packages/ui/spa/hooks/useAI.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,37 @@ import {
66
useWsMessages,
77
} from "../components/ValProvider";
88
import type { WsExtendedMessage } from "./useStatus";
9+
import { useAISearch } from "./useAISearch";
910

1011
const GET_ALL_SCHEMA_TOOL: AITool = {
1112
name: "get_all_schema",
1213
description:
1314
"Get all val schemas — returns the complete schema definitions for all val modules",
14-
parameters: { type: "object", properties: {} },
15+
parameters: {
16+
type: "object",
17+
properties: {},
18+
required: [],
19+
},
1520
};
21+
const SEARCH_CONTENT_TOOL: AITool = {
22+
name: "search_content",
23+
description:
24+
"Search content — accepts a query and returns a list of matching content items",
25+
parameters: {
26+
type: "object",
27+
properties: {
28+
query: { type: "string", description: "The search query" },
29+
},
30+
required: ["query"],
31+
},
32+
};
33+
const ALL_TOOLS: AITool[] = [GET_ALL_SCHEMA_TOOL, SEARCH_CONTENT_TOOL];
1634

1735
export function useAI(chatRef: React.RefObject<AIChatHandle | null>) {
1836
const { subscribeToWsMessages, sendWsMessage, isWsConnected } =
1937
useWsMessages();
2038
const syncEngine = useSyncEngine();
39+
const aiSearch = useAISearch();
2140
const [isStreaming, setIsStreaming] = useState(false);
2241
// Track active streaming ID — startAssistantMessage always appends a new
2342
// message (NOT idempotent), so we must only call it once per message ID.
@@ -50,6 +69,13 @@ export function useAI(chatRef: React.RefObject<AIChatHandle | null>) {
5069
toolCallId: message.toolCallId,
5170
result: schemas ?? {},
5271
});
72+
} else if (message.name === "search_content") {
73+
const args = message.arguments as { query: string };
74+
75+
aiSearch.query(args.query, message.toolCallId);
76+
} else {
77+
const exhaustiveCheck: never = message.name;
78+
console.error("Received unknown tool call in useAI", exhaustiveCheck);
5379
}
5480
} else {
5581
console.error("Received unknown message type in useAI", message);
@@ -58,7 +84,7 @@ export function useAI(chatRef: React.RefObject<AIChatHandle | null>) {
5884
};
5985

6086
return subscribeToWsMessages(handler);
61-
}, [subscribeToWsMessages, sendWsMessage, syncEngine, chatRef]);
87+
}, [subscribeToWsMessages, sendWsMessage, syncEngine, aiSearch, chatRef]);
6288

6389
const sendMessage = useCallback(
6490
(text: string) => {
@@ -71,7 +97,7 @@ export function useAI(chatRef: React.RefObject<AIChatHandle | null>) {
7197
- Users are working in another code base where they have Val installed, and are using you to ask questions about their content schemas, and to get help writing content.
7298
- Be concise, accurate, and helpful.`,
7399
id: crypto.randomUUID(),
74-
tools: [GET_ALL_SCHEMA_TOOL],
100+
tools: ALL_TOOLS,
75101
});
76102
},
77103
[sendWsMessage],
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { useEffect, useRef, useCallback, useSyncExternalStore } from "react";
2+
import { Json, ModuleFilePath, SerializedSchema } from "@valbuild/core";
3+
import type { WorkerRequest, WorkerResponse } from "../search/worker-types";
4+
import type { SearchResult } from "../search/useSearchWorker";
5+
import { useWsMessages, useSyncEngine } from "../components/ValProvider";
6+
7+
export function useAISearch() {
8+
const { sendWsMessage } = useWsMessages();
9+
const syncEngine = useSyncEngine();
10+
11+
const schemas = useSyncExternalStore(
12+
syncEngine.subscribe("schema"),
13+
() => syncEngine.getAllSchemasSnapshot(),
14+
() => syncEngine.getAllSchemasSnapshot(),
15+
);
16+
const sources = useSyncExternalStore(
17+
syncEngine.subscribe("all-sources"),
18+
() => syncEngine.getAllSourcesSnapshot(),
19+
() => syncEngine.getAllSourcesSnapshot(),
20+
);
21+
22+
const workerRef = useRef<Worker | null>(null);
23+
const isIndexBuiltRef = useRef(false);
24+
const requestIdCounter = useRef(0);
25+
const pendingRequests = useRef<
26+
Map<
27+
string,
28+
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
29+
>
30+
>(new Map());
31+
32+
useEffect(() => {
33+
isIndexBuiltRef.current = false;
34+
}, [schemas, sources]);
35+
36+
useEffect(() => {
37+
const worker = new Worker(
38+
new URL("../search/search.worker.ts", import.meta.url),
39+
{ type: "module" },
40+
);
41+
workerRef.current = worker;
42+
43+
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
44+
const response = event.data;
45+
const pending = pendingRequests.current.get(response.id);
46+
if (!pending) return;
47+
pendingRequests.current.delete(response.id);
48+
if (response.type === "index-ready") {
49+
isIndexBuiltRef.current = true;
50+
pending.resolve(null);
51+
} else if (response.type === "search-results") {
52+
pending.resolve(response.results);
53+
} else if (response.type === "error") {
54+
pending.reject(new Error(response.error));
55+
}
56+
};
57+
58+
return () => {
59+
worker.terminate();
60+
workerRef.current = null;
61+
};
62+
}, []);
63+
64+
const query = useCallback(
65+
async (searchQuery: string, toolCallId: string) => {
66+
if (!workerRef.current) return;
67+
try {
68+
if (!isIndexBuiltRef.current) {
69+
const modules: Record<
70+
ModuleFilePath,
71+
{ source: Json; schema: SerializedSchema }
72+
> = {};
73+
for (const path in schemas) {
74+
const moduleFilePath = path as ModuleFilePath;
75+
const source = sources?.[moduleFilePath];
76+
if (source !== undefined) {
77+
modules[moduleFilePath] = {
78+
source,
79+
schema: schemas[moduleFilePath],
80+
};
81+
}
82+
}
83+
const buildId = `ai-build-${requestIdCounter.current++}`;
84+
await new Promise((resolve, reject) => {
85+
pendingRequests.current.set(buildId, { resolve, reject });
86+
workerRef.current!.postMessage({
87+
type: "build-index",
88+
id: buildId,
89+
modules,
90+
} satisfies WorkerRequest);
91+
});
92+
}
93+
94+
const results = await new Promise<SearchResult[]>((resolve, reject) => {
95+
pendingRequests.current.set(toolCallId, {
96+
resolve: resolve as (value: unknown) => void,
97+
reject,
98+
});
99+
workerRef.current!.postMessage({
100+
type: "search",
101+
id: toolCallId,
102+
query: searchQuery,
103+
} satisfies WorkerRequest);
104+
});
105+
106+
console.log("Search results for query", searchQuery, results, {
107+
toolCallId,
108+
});
109+
sendWsMessage({ type: "ai_tool_result", toolCallId, result: results });
110+
} catch (error) {
111+
sendWsMessage({
112+
type: "ai_tool_result",
113+
toolCallId,
114+
result: String(error),
115+
isError: true,
116+
});
117+
}
118+
},
119+
[schemas, sources, sendWsMessage],
120+
);
121+
122+
return { query };
123+
}

packages/ui/spa/hooks/useStatus.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import React, {
1010
useCallback,
1111
} from "react";
1212
import { z } from "zod";
13+
import { toolNames } from "../utils/toolNames";
1314

1415
const PatchId = z
1516
.string()
@@ -36,7 +37,7 @@ export const AIToolCallMessage = z.object({
3637
type: z.literal("ai_tool_call"),
3738
id: z.string(),
3839
toolCallId: z.string(),
39-
name: z.string(),
40+
name: z.enum(toolNames),
4041
arguments: z.unknown(),
4142
});
4243
export const AIServerMessage = z.union([

packages/ui/spa/utils/toolNames.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const toolNames = ["get_all_schema", "search_content"] as const;

0 commit comments

Comments
 (0)