From 8f6ad5239db17ccd9309b774787debf0968d2fe5 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Fri, 12 Jun 2026 16:01:31 +0000 Subject: [PATCH] Add duplicate_source,empty_at_path,count_entries,get_record_keys tools --- packages/ui/spa/components/AIChat.tsx | 20 + packages/ui/spa/hooks/aiSourceToolPatches.ts | 218 +++++++++ packages/ui/spa/hooks/useAI.ts | 482 +++++++++++++++++++ packages/ui/spa/utils/toolNames.ts | 4 + 4 files changed, 724 insertions(+) create mode 100644 packages/ui/spa/hooks/aiSourceToolPatches.ts diff --git a/packages/ui/spa/components/AIChat.tsx b/packages/ui/spa/components/AIChat.tsx index 3549e0941..c7a90c12f 100644 --- a/packages/ui/spa/components/AIChat.tsx +++ b/packages/ui/spa/components/AIChat.tsx @@ -33,6 +33,10 @@ import { Paperclip, GitCompareArrows, X, + Copy, + FilePlus, + Hash, + List, } from "lucide-react"; import type { AISession } from "../hooks/useAIWebSocket"; import type { AIContentBlock, AIMessageContent } from "./ValProvider"; @@ -1481,6 +1485,22 @@ const TOOL_DISPLAY: Record = label: "Opening compare view", icon: , }, + duplicate_source: { + label: "Duplicating content", + icon: , + }, + empty_at_path: { + label: "Creating empty entry", + icon: , + }, + count_entries: { + label: "Counting entries", + icon: , + }, + get_record_keys: { + label: "Listing keys", + icon: , + }, }; function ToolActivitiesIndicator({ diff --git a/packages/ui/spa/hooks/aiSourceToolPatches.ts b/packages/ui/spa/hooks/aiSourceToolPatches.ts new file mode 100644 index 000000000..7a5abd81c --- /dev/null +++ b/packages/ui/spa/hooks/aiSourceToolPatches.ts @@ -0,0 +1,218 @@ +import type { SerializedSchema, Source } from "@valbuild/core"; +import { emptyOf } from "../components/fields/emptyOf"; +import { + getSourceAt, + resolveSerializedSchemaAtPath, + safeParsePatch, + type BuildResult, +} from "./aiImageToolPatches"; +import type { ToolName } from "../utils/toolNames"; + +type OpDecision = + | { kind: "ok"; op: "add" | "replace" } + | { kind: "wrong-tool"; suggestedTool: ToolName; reason: string } + | { kind: "error"; message: string }; + +/** + * Decides whether the duplicate/empty operation should produce an "add" or + * "replace" JSON patch op by inspecting the destination's *parent* schema. + * + * - array / record parent → "add" (creating a new slot) + * - object parent → "replace" (slot exists by schema definition) + * - empty path → "replace" (overwriting the whole module) + * - richtext / gallery parent → not supported; redirect to the right tool + */ +function decideOp( + moduleSchema: SerializedSchema, + destinationPath: string[], +): OpDecision { + if (destinationPath.length === 0) { + return { kind: "ok", op: "replace" }; + } + const parent = resolveSerializedSchemaAtPath( + moduleSchema, + destinationPath.slice(0, -1), + ); + if (parent.kind === "unresolved") { + return { + kind: "error", + message: `Destination parent path ${JSON.stringify( + destinationPath.slice(0, -1), + )} does not resolve in this module's schema.`, + }; + } + if (parent.kind === "richtext") { + return { + kind: "wrong-tool", + suggestedTool: "create_patch", + reason: + "Destination is inside a richtext value. duplicate_source and empty_at_path do not handle richtext edits.", + }; + } + if (parent.kind === "gallery-traversed") { + return { + kind: "wrong-tool", + suggestedTool: "add_session_image_to_gallery", + reason: + "Destination is inside an images gallery (s.images()). Use add_session_image_to_gallery to add entries.", + }; + } + const schema = parent.schema; + if (schema.type === "array" || schema.type === "record") { + return { kind: "ok", op: "add" }; + } + if (schema.type === "object") { + return { kind: "ok", op: "replace" }; + } + return { + kind: "error", + message: `Destination parent has schema type "${schema.type}" — cannot use duplicate_source/empty_at_path here. Use create_patch instead.`, + }; +} + +export function buildDuplicatePatch( + args: { sourcePath: string[]; destinationPath: string[] }, + moduleSchema: SerializedSchema, + moduleSource: Source | undefined, +): BuildResult { + const value = getSourceAt(moduleSource, args.sourcePath); + if (value === undefined) { + return { + kind: "error", + message: `Source path ${JSON.stringify( + args.sourcePath, + )} does not exist in the module. Use get_source to inspect the current contents.`, + }; + } + const decision = decideOp(moduleSchema, args.destinationPath); + if (decision.kind === "wrong-tool") { + return { + kind: "wrong-tool", + suggestedTool: decision.suggestedTool, + reason: decision.reason, + }; + } + if (decision.kind === "error") { + return { kind: "error", message: decision.message }; + } + return safeParsePatch([ + { + op: decision.op, + path: args.destinationPath, + value, + }, + ]); +} + +export function buildEmptyAtPathPatch( + args: { destinationPath: string[] }, + moduleSchema: SerializedSchema, +): BuildResult { + let destinationSchema: SerializedSchema; + if (args.destinationPath.length === 0) { + destinationSchema = moduleSchema; + } else { + const result = resolveSerializedSchemaAtPath( + moduleSchema, + args.destinationPath, + ); + if (result.kind === "unresolved") { + return { + kind: "error", + message: `Destination path ${JSON.stringify( + args.destinationPath, + )} does not resolve in this module's schema.`, + }; + } + if (result.kind === "richtext") { + return { + kind: "wrong-tool", + suggestedTool: "create_patch", + reason: + "Destination is a richtext value. Use create_patch to build richtext content.", + }; + } + if (result.kind === "gallery-traversed") { + return { + kind: "wrong-tool", + suggestedTool: "add_session_image_to_gallery", + reason: + "Destination is inside an images gallery. Use add_session_image_to_gallery to add entries.", + }; + } + destinationSchema = result.schema; + } + const decision = decideOp(moduleSchema, args.destinationPath); + if (decision.kind === "wrong-tool") { + return { + kind: "wrong-tool", + suggestedTool: decision.suggestedTool, + reason: decision.reason, + }; + } + if (decision.kind === "error") { + return { kind: "error", message: decision.message }; + } + const value = emptyOf(destinationSchema); + return safeParsePatch([ + { + op: decision.op, + path: args.destinationPath, + value, + }, + ]); +} + +export type ContainerKind = "array" | "record" | "object"; + +export type DescribeContainerResult = + | { kind: "ok"; container: ContainerKind; value: Source } + | { kind: "error"; message: string }; + +/** + * Walks the schema at `path` to discriminate record vs object (they look the + * same at runtime). Arrays are recognized from the source value itself. + * Used by count_entries and get_record_keys. + */ +export function describeContainerAtPath( + moduleSchema: SerializedSchema, + moduleSource: Source | undefined, + path: string[], +): DescribeContainerResult { + const value = getSourceAt(moduleSource, path); + if (value === undefined) { + return { + kind: "error", + message: `Path ${JSON.stringify( + path, + )} does not exist in the module source. Use get_source to inspect the current contents.`, + }; + } + if (Array.isArray(value)) { + return { kind: "ok", container: "array", value }; + } + if (value === null || typeof value !== "object") { + return { + kind: "error", + message: `Path ${JSON.stringify( + path, + )} points to a ${value === null ? "null" : typeof value} value, which has no entries to count or list.`, + }; + } + // Object-like at runtime. Distinguish record vs object via schema. + let schemaKind: ContainerKind = "object"; + if (path.length === 0) { + if (moduleSchema.type === "record") schemaKind = "record"; + else if (moduleSchema.type === "object") schemaKind = "object"; + else schemaKind = "object"; + } else { + const resolved = resolveSerializedSchemaAtPath(moduleSchema, path); + if (resolved.kind === "leaf") { + if (resolved.schema.type === "record") schemaKind = "record"; + else if (resolved.schema.type === "object") schemaKind = "object"; + } + // For richtext / gallery-traversed / unresolved we fall through with the + // value (richtext nodes are objects too) and report kind: "object". + } + return { kind: "ok", container: schemaKind, value }; +} diff --git a/packages/ui/spa/hooks/useAI.ts b/packages/ui/spa/hooks/useAI.ts index 55032f765..1110e29f0 100644 --- a/packages/ui/spa/hooks/useAI.ts +++ b/packages/ui/spa/hooks/useAI.ts @@ -44,6 +44,11 @@ import { isRemoteSchema, type BuildResult, } from "./aiImageToolPatches"; +import { + buildDuplicatePatch, + buildEmptyAtPathPatch, + describeContainerAtPath, +} from "./aiSourceToolPatches"; import { expandSessionKeysInPatch, type ExpandResult, @@ -326,6 +331,126 @@ const SHOW_COMPARE_VIEW_TOOL: AITool = { required: [], }, }; +const PATH_ARRAY_DESCRIPTION = + "JSON Pointer AS AN ARRAY (e.g. ['posts', 'blog-1'] or ['items', '0', 'title']). " + + "Array indices are numeric strings. Record keys that contain slashes stay as a single segment, e.g. ['/foo/bar']."; +const DUPLICATE_SOURCE_TOOL: AITool = { + name: "duplicate_source", + description: + "Duplicate the value at 'source_path' to 'destination_path' within the same Val module. " + + "Use the op rule: if the destination's parent is an array or record, the patch uses 'add' (creating a new entry); " + + "if the destination's parent is an object, the patch uses 'replace'; if destination_path is empty, replaces the whole module. " + + "PREFER this tool over empty_at_path when there is an existing similar entry to copy — copying preserves nested structure, " + + "optional fields, and image references correctly, while emptying often produces nulls the model would have to fill in. " + + "For richtext destinations or images-gallery destinations, use create_patch or add_session_image_to_gallery instead.", + parameters: { + type: "object", + properties: { + module_file_path: { + type: "string", + description: "The Val module file path, e.g. '/content/blog.val.ts'.", + }, + source_path: { + type: "array", + items: { type: "string" }, + description: + "Path within the module to read FROM. " + PATH_ARRAY_DESCRIPTION, + }, + destination_path: { + type: "array", + items: { type: "string" }, + description: + "Path within the SAME module to write TO. " + PATH_ARRAY_DESCRIPTION, + }, + }, + required: ["module_file_path", "source_path", "destination_path"], + }, +}; +const EMPTY_AT_PATH_TOOL: AITool = { + name: "empty_at_path", + description: + "Create an empty value (built from the schema via emptyOf) at 'destination_path' in a Val module. " + + "Same op rule as duplicate_source (array/record parent → 'add', object parent → 'replace', empty path → 'replace'). " + + "Use this ONLY when there is no good donor to duplicate from — duplicate_source is almost always better when something similar exists, " + + "because empty values for optional fields become null and image/file fields become null (which then need a follow-up upload). " + + "For richtext destinations or images-gallery destinations, use create_patch or add_session_image_to_gallery instead.", + parameters: { + type: "object", + properties: { + module_file_path: { + type: "string", + description: "The Val module file path, e.g. '/content/blog.val.ts'.", + }, + destination_path: { + type: "array", + items: { type: "string" }, + description: + "Path within the module to write to. " + PATH_ARRAY_DESCRIPTION, + }, + }, + required: ["module_file_path", "destination_path"], + }, +}; +const COUNT_ENTRIES_TOOL: AITool = { + name: "count_entries", + description: + "Count the entries of the value at 'path' in a Val module — record keys, object keys, array indices, or richtext block-children. " + + "Use this before paging through a large record/array or to answer 'how many X are there?' without pulling the entire module. " + + "Returns { kind: 'array' | 'record' | 'object', count: number }. " + + "Fails for primitive (string/number/boolean/null) values.", + parameters: { + type: "object", + properties: { + module_file_path: { + type: "string", + description: "The Val module file path, e.g. '/content/blog.val.ts'.", + }, + path: { + type: "array", + items: { type: "string" }, + description: + "Path within the module pointing at the collection to count. " + + "Pass [] for the module root. " + + PATH_ARRAY_DESCRIPTION, + }, + }, + required: ["module_file_path", "path"], + }, +}; +const GET_RECORD_KEYS_TOOL: AITool = { + name: "get_record_keys", + description: + "List the keys of the record (or object) at 'path' in a Val module, with pagination. " + + "Use this to enumerate entries without pulling their contents — e.g. to find which slug to operate on next. " + + "Returns { kind: 'record' | 'object', keys: string[], total: number }. " + + "Fails on arrays, primitives, richtext, or galleries.", + parameters: { + type: "object", + properties: { + module_file_path: { + type: "string", + description: "The Val module file path, e.g. '/content/blog.val.ts'.", + }, + path: { + type: "array", + items: { type: "string" }, + description: + "Path within the module pointing at the record/object. " + + "Pass [] for the module root. " + + PATH_ARRAY_DESCRIPTION, + }, + limit: { + type: "number", + description: "Max keys to return (default 100).", + }, + offset: { + type: "number", + description: "Number of keys to skip for pagination (default 0).", + }, + }, + required: ["module_file_path", "path"], + }, +}; const ALL_TOOLS: AITool[] = [ GET_ALL_SCHEMA_TOOL, GET_SOURCE_TOOL, @@ -340,6 +465,10 @@ const ALL_TOOLS: AITool[] = [ GET_SOURCE_PATH_FROM_ROUTE_TOOL, SET_SESSION_NAME_TOOL, SHOW_COMPARE_VIEW_TOOL, + DUPLICATE_SOURCE_TOOL, + EMPTY_AT_PATH_TOOL, + COUNT_ENTRIES_TOOL, + GET_RECORD_KEYS_TOOL, ]; export type UseAIOptions = { @@ -1217,6 +1346,359 @@ export function useAI( result: { success: true }, }); chatRef.current?.completeToolCall(message.id, message.toolCallId); + } else if ( + message.name === "duplicate_source" || + message.name === "empty_at_path" + ) { + const toolName = message.name; + const args = message.arguments as { + module_file_path?: string; + source_path?: unknown; + destination_path?: unknown; + }; + const toolCallId = message.toolCallId; + const messageId = message.id; + (async () => { + if (!args.module_file_path) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { + success: false, + error: "Missing required argument: module_file_path.", + }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + return; + } + const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.every((p) => typeof p === "string"); + if (!isStringArray(args.destination_path)) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { + success: false, + error: + "destination_path must be an array of strings (path segments).", + }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + return; + } + if ( + toolName === "duplicate_source" && + !isStringArray(args.source_path) + ) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { + success: false, + error: + "source_path must be an array of strings (path segments).", + }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + return; + } + const moduleFilePath = args.module_file_path as ModuleFilePath; + const schemas = syncEngine.getAllSchemasSnapshot(); + const moduleSchema = schemas?.[moduleFilePath]; + if (!moduleSchema) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { + success: false, + error: `Module not found: '${moduleFilePath}'. Use get_all_schema to see available modules.`, + }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + return; + } + let buildResult: BuildResult; + if (toolName === "duplicate_source") { + const sourceSnap = syncEngine.getSourceSnapshot(moduleFilePath); + const sourceData = + sourceSnap.status === "success" + ? (sourceSnap.data as Source | undefined) + : undefined; + buildResult = buildDuplicatePatch( + { + sourcePath: args.source_path as string[], + destinationPath: args.destination_path, + }, + moduleSchema, + sourceData, + ); + } else { + buildResult = buildEmptyAtPathPatch( + { destinationPath: args.destination_path }, + moduleSchema, + ); + } + if (buildResult.kind === "wrong-tool") { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { + success: false, + error: `${buildResult.reason} Retry with ${buildResult.suggestedTool}.`, + suggestedTool: buildResult.suggestedTool, + }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + return; + } + if (buildResult.kind === "error") { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { success: false, error: buildResult.message }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + return; + } + const patch = buildResult.patch; + const validationResult = syncEngine.validatePatchResult( + moduleFilePath, + patch, + ); + if (validationResult && "status" in validationResult) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { success: false, error: validationResult.message }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + return; + } + if (validationResult !== false) { + const blockingErrors = filterBlockingValidationErrors( + validationResult, + syncEngine.getAllSchemasSnapshot(), + syncEngine.getAllSourcesSnapshot(), + ); + if (Object.keys(blockingErrors).length > 0) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { + success: false, + error: `Patch produces validation errors: ${JSON.stringify( + blockingErrors, + )}`, + }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + return; + } + } + const patchId = syncEngine.createPatchId(); + const patchRes = await syncEngine.addPatchAwaitable( + moduleFilePath, + moduleSchema.type, + patch, + patchId, + sessionIdRef.current, + Date.now(), + ); + if (patchRes.status === "patch-synced") { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { + success: true, + patchId: patchRes.patchId, + message: + toolName === "duplicate_source" + ? "Duplicated successfully." + : "Empty value created successfully.", + }, + }); + chatRef.current?.completeToolCall(messageId, toolCallId); + } else { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { success: false, error: patchRes.message }, + isError: true, + }); + chatRef.current?.errorToolCall(messageId, toolCallId); + } + })(); + } else if (message.name === "count_entries") { + const args = message.arguments as { + module_file_path?: string; + path?: unknown; + }; + if ( + !args.module_file_path || + !Array.isArray(args.path) || + !args.path.every((p) => typeof p === "string") + ) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { + success: false, + error: + "count_entries requires module_file_path (string) and path (string[]).", + }, + isError: true, + }); + chatRef.current?.errorToolCall(message.id, message.toolCallId); + return; + } + const moduleFilePath = args.module_file_path as ModuleFilePath; + const schemas = syncEngine.getAllSchemasSnapshot(); + const moduleSchema = schemas?.[moduleFilePath]; + if (!moduleSchema) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { + success: false, + error: `Module not found: '${moduleFilePath}'. Use get_all_schema to see available modules.`, + }, + isError: true, + }); + chatRef.current?.errorToolCall(message.id, message.toolCallId); + return; + } + const sourceSnap = syncEngine.getSourceSnapshot(moduleFilePath); + const sourceData = + sourceSnap.status === "success" + ? (sourceSnap.data as Source | undefined) + : undefined; + const described = describeContainerAtPath( + moduleSchema, + sourceData, + args.path, + ); + if (described.kind === "error") { + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { success: false, error: described.message }, + isError: true, + }); + chatRef.current?.errorToolCall(message.id, message.toolCallId); + return; + } + const count = Array.isArray(described.value) + ? described.value.length + : Object.keys(described.value as Record).length; + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { + success: true, + kind: described.container, + count, + }, + }); + chatRef.current?.completeToolCall(message.id, message.toolCallId); + } else if (message.name === "get_record_keys") { + const args = message.arguments as { + module_file_path?: string; + path?: unknown; + limit?: number; + offset?: number; + }; + if ( + !args.module_file_path || + !Array.isArray(args.path) || + !args.path.every((p) => typeof p === "string") + ) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { + success: false, + error: + "get_record_keys requires module_file_path (string) and path (string[]).", + }, + isError: true, + }); + chatRef.current?.errorToolCall(message.id, message.toolCallId); + return; + } + const moduleFilePath = args.module_file_path as ModuleFilePath; + const schemas = syncEngine.getAllSchemasSnapshot(); + const moduleSchema = schemas?.[moduleFilePath]; + if (!moduleSchema) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { + success: false, + error: `Module not found: '${moduleFilePath}'. Use get_all_schema to see available modules.`, + }, + isError: true, + }); + chatRef.current?.errorToolCall(message.id, message.toolCallId); + return; + } + const sourceSnap = syncEngine.getSourceSnapshot(moduleFilePath); + const sourceData = + sourceSnap.status === "success" + ? (sourceSnap.data as Source | undefined) + : undefined; + const described = describeContainerAtPath( + moduleSchema, + sourceData, + args.path, + ); + if (described.kind === "error") { + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { success: false, error: described.message }, + isError: true, + }); + chatRef.current?.errorToolCall(message.id, message.toolCallId); + return; + } + if (described.container === "array") { + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { + success: false, + error: + "Path points to an array. get_record_keys only works on records and objects — use count_entries to get the array length.", + }, + isError: true, + }); + chatRef.current?.errorToolCall(message.id, message.toolCallId); + return; + } + const allKeys = Object.keys( + described.value as Record, + ); + const limit = typeof args.limit === "number" ? args.limit : 100; + const offset = typeof args.offset === "number" ? args.offset : 0; + sendWsMessage({ + type: "ai_tool_result", + toolCallId: message.toolCallId, + result: { + success: true, + kind: described.container, + keys: allKeys.slice(offset, offset + limit), + total: allKeys.length, + }, + }); + chatRef.current?.completeToolCall(message.id, message.toolCallId); } else { console.error("Received unknown tool call in useAI", message.name); sendWsMessage({ diff --git a/packages/ui/spa/utils/toolNames.ts b/packages/ui/spa/utils/toolNames.ts index 666555af5..69563e0fc 100644 --- a/packages/ui/spa/utils/toolNames.ts +++ b/packages/ui/spa/utils/toolNames.ts @@ -12,5 +12,9 @@ export const toolNames = [ "get_source_path_from_route", "set_session_name", "show_compare_view", + "duplicate_source", + "empty_at_path", + "count_entries", + "get_record_keys", ] as const; export type ToolName = (typeof toolNames)[number];