@fayz-ai/saas 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access/context.d.ts +47 -0
- package/dist/access/context.d.ts.map +1 -0
- package/dist/access/headless.d.ts +21 -0
- package/dist/access/headless.d.ts.map +1 -0
- package/dist/access/index.d.ts +12 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/limits-registry.d.ts +33 -0
- package/dist/access/limits-registry.d.ts.map +1 -0
- package/dist/access/module-nav.d.ts +15 -0
- package/dist/access/module-nav.d.ts.map +1 -0
- package/dist/access/resolver.d.ts +2 -0
- package/dist/access/resolver.d.ts.map +1 -0
- package/dist/access/types.d.ts +43 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/upgrade-modal-store.d.ts +23 -0
- package/dist/access/upgrade-modal-store.d.ts.map +1 -0
- package/dist/app/AdminShell.d.ts +3 -0
- package/dist/app/AdminShell.d.ts.map +1 -1
- package/dist/app/admin-app.d.ts +3 -1
- package/dist/app/admin-app.d.ts.map +1 -1
- package/dist/app/config.d.ts +47 -2
- package/dist/app/config.d.ts.map +1 -1
- package/dist/app/core-entities.d.ts +25 -0
- package/dist/app/core-entities.d.ts.map +1 -0
- package/dist/app/derive-contract.d.ts +8 -0
- package/dist/app/derive-contract.d.ts.map +1 -0
- package/dist/app/scaffold.d.ts.map +1 -1
- package/dist/archetype-lookup.d.ts.map +1 -1
- package/dist/billing/index.cjs +6 -2
- package/dist/billing/index.d.ts +2 -0
- package/dist/billing/index.d.ts.map +1 -1
- package/dist/billing/index.js +1 -1
- package/dist/billing/plan-badge.d.ts +17 -0
- package/dist/billing/plan-badge.d.ts.map +1 -0
- package/dist/billing/store.d.ts +6 -1
- package/dist/billing/store.d.ts.map +1 -1
- package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
- package/dist/chunk-I3DY47N5.js.map +1 -0
- package/dist/chunk-NNFGMUF2.cjs +39 -0
- package/dist/chunk-NNFGMUF2.cjs.map +1 -0
- package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
- package/dist/chunk-SAZLW54Y.cjs.map +1 -0
- package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
- package/dist/chunk-VKYZACG5.cjs.map +1 -0
- package/dist/chunk-VN5NTK26.js +36 -0
- package/dist/chunk-VN5NTK26.js.map +1 -0
- package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
- package/dist/chunk-VTEP2AQX.js.map +1 -0
- package/dist/components/shared/ContactPicker.d.ts +92 -0
- package/dist/components/shared/ContactPicker.d.ts.map +1 -0
- package/dist/components/shared/PersonLink.d.ts +12 -1
- package/dist/components/shared/PersonLink.d.ts.map +1 -1
- package/dist/components/shared/create-person.d.ts +20 -0
- package/dist/components/shared/create-person.d.ts.map +1 -0
- package/dist/crud/CrudDetailPage.d.ts.map +1 -1
- package/dist/crud/CrudFormPage.d.ts.map +1 -1
- package/dist/crud/CrudListView.d.ts.map +1 -1
- package/dist/crud/CrudPage.d.ts.map +1 -1
- package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
- package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
- package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
- package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
- package/dist/crud/createCrudPage.d.ts.map +1 -1
- package/dist/crud/index.d.ts +1 -0
- package/dist/crud/index.d.ts.map +1 -1
- package/dist/index.cjs +4300 -722
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4080 -542
- package/dist/index.js.map +1 -1
- package/dist/org/index.cjs +11 -11
- package/dist/org/index.js +2 -2
- package/dist/org/store.d.ts.map +1 -1
- package/dist/permissions/context.d.ts +2 -0
- package/dist/permissions/context.d.ts.map +1 -1
- package/dist/permissions/index.cjs +7 -7
- package/dist/permissions/index.js +1 -1
- package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
- package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
- package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
- package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
- package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
- package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
- package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
- package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
- package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
- package/dist/shell/components/billing/access-contract.d.ts +18 -0
- package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
- package/dist/shell/components/billing/gates.d.ts +14 -0
- package/dist/shell/components/billing/gates.d.ts.map +1 -0
- package/dist/shell/components/billing/index.d.ts +9 -2
- package/dist/shell/components/billing/index.d.ts.map +1 -1
- package/dist/shell/components/chat/ChatFab.d.ts +3 -1
- package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
- package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
- package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
- package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
- package/dist/shell/components/chat/markdown.d.ts +5 -0
- package/dist/shell/components/chat/markdown.d.ts.map +1 -0
- package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
- package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
- package/dist/shell/components/organization/index.d.ts +0 -1
- package/dist/shell/components/organization/index.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
- package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
- package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
- package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
- package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
- package/dist/shell/hooks/index.d.ts +3 -2
- package/dist/shell/hooks/index.d.ts.map +1 -1
- package/dist/shell/hooks/useAITools.d.ts +2 -0
- package/dist/shell/hooks/useAITools.d.ts.map +1 -1
- package/dist/shell/hooks/useChat.d.ts +22 -0
- package/dist/shell/hooks/useChat.d.ts.map +1 -1
- package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
- package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
- package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
- package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
- package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
- package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
- package/dist/shell/lib/core-ai-tools.d.ts +14 -0
- package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
- package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
- package/dist/shell/lib/fayz-agent.d.ts +23 -0
- package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
- package/dist/shell/lib/i18n.d.ts.map +1 -1
- package/dist/shell/stores/chat.store.d.ts +69 -0
- package/dist/shell/stores/chat.store.d.ts.map +1 -1
- package/dist/shell/types/billing.d.ts +7 -0
- package/dist/shell/types/billing.d.ts.map +1 -1
- package/dist/shell/types/crud.d.ts +1 -1
- package/dist/shell/types/crud.d.ts.map +1 -1
- package/dist/shell/types/plugins.d.ts +2 -45
- package/dist/shell/types/plugins.d.ts.map +1 -1
- package/dist/stores/createCrudStore.d.ts.map +1 -1
- package/package.json +15 -12
- package/src/access/context.tsx +228 -0
- package/src/access/headless.ts +71 -0
- package/src/access/index.ts +27 -0
- package/src/access/limits-registry.ts +86 -0
- package/src/access/module-nav.test.ts +78 -0
- package/src/access/module-nav.ts +82 -0
- package/src/access/resolver.test.ts +143 -0
- package/src/access/resolver.ts +5 -0
- package/src/access/types.ts +47 -0
- package/src/access/upgrade-modal-store.ts +30 -0
- package/src/app/AdminShell.tsx +451 -68
- package/src/app/admin-app.tsx +46 -9
- package/src/app/config.ts +54 -1
- package/src/app/core-entities.ts +110 -0
- package/src/app/derive-contract.test.ts +155 -0
- package/src/app/derive-contract.ts +214 -0
- package/src/app/scaffold.tsx +4 -0
- package/src/archetype-lookup.ts +8 -2
- package/src/billing/index.ts +2 -0
- package/src/billing/plan-badge.ts +32 -0
- package/src/billing/store.ts +8 -1
- package/src/components/shared/ContactPicker.tsx +394 -0
- package/src/components/shared/PersonLink.tsx +11 -3
- package/src/components/shared/create-person.ts +74 -0
- package/src/crud/CrudDetailPage.tsx +8 -1
- package/src/crud/CrudFormPage.tsx +124 -6
- package/src/crud/CrudListView.tsx +16 -1
- package/src/crud/CrudPage.tsx +27 -5
- package/src/crud/DeleteConfirmDialog.tsx +1 -1
- package/src/crud/archetypes/AddressesTab.tsx +387 -0
- package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
- package/src/crud/createCrudPage.tsx +2 -0
- package/src/crud/index.ts +6 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +49 -1
- package/src/org/store.ts +9 -1
- package/src/permissions/context.tsx +29 -15
- package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
- package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
- package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
- package/src/shell/components/billing/UpgradeModal.tsx +102 -0
- package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
- package/src/shell/components/billing/access-contract.ts +64 -0
- package/src/shell/components/billing/gates.tsx +62 -0
- package/src/shell/components/billing/index.ts +18 -2
- package/src/shell/components/chat/ChatFab.tsx +4 -2
- package/src/shell/components/chat/ChatPanel.tsx +174 -23
- package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
- package/src/shell/components/chat/markdown.tsx +102 -0
- package/src/shell/components/organization/TeamTab.tsx +21 -4
- package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
- package/src/shell/components/organization/index.ts +0 -1
- package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
- package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
- package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
- package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
- package/src/shell/components/settings/SettingsPage.tsx +63 -21
- package/src/shell/hooks/index.ts +3 -2
- package/src/shell/hooks/useAITools.ts +27 -23
- package/src/shell/hooks/useChat.ts +381 -16
- package/src/shell/hooks/useSystemPermission.ts +23 -0
- package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
- package/src/shell/lib/ai-tool-handlers.ts +910 -0
- package/src/shell/lib/core-ai-tools.ts +234 -2
- package/src/shell/lib/create-client-orders-provider.ts +24 -2
- package/src/shell/lib/fayz-agent.ts +56 -0
- package/src/shell/lib/i18n.ts +71 -0
- package/src/shell/stores/chat.store.ts +118 -1
- package/src/shell/styles.css +30 -0
- package/src/shell/types/billing.ts +8 -0
- package/src/shell/types/crud.ts +1 -1
- package/src/shell/types/plugins.ts +13 -50
- package/src/stores/createCrudStore.ts +9 -0
- package/dist/chunk-3M25GRRU.js.map +0 -1
- package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
- package/dist/chunk-5GYQK5IW.js +0 -21
- package/dist/chunk-5GYQK5IW.js.map +0 -1
- package/dist/chunk-IPWCHJPE.cjs.map +0 -1
- package/dist/chunk-KYID2MR6.cjs +0 -23
- package/dist/chunk-KYID2MR6.cjs.map +0 -1
- package/dist/chunk-XNWCGJYH.js.map +0 -1
- package/dist/shell/components/billing/BillingPage.d.ts +0 -7
- package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
- package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
- package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
- package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
- package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
- package/dist/shell/hooks/useBilling.d.ts +0 -14
- package/dist/shell/hooks/useBilling.d.ts.map +0 -1
- package/dist/shell/hooks/usePermission.d.ts +0 -16
- package/dist/shell/hooks/usePermission.d.ts.map +0 -1
- package/src/shell/components/billing/BillingPage.tsx +0 -56
- package/src/shell/components/billing/PaywallGate.tsx +0 -80
- package/src/shell/components/organization/PermissionGate.tsx +0 -16
- package/src/shell/hooks/useBilling.ts +0 -151
- package/src/shell/hooks/usePermission.ts +0 -101
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
import { resolveDataProvider, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
2
|
+
import { checkAccess, guardLimit } from '../../access/headless'
|
|
3
|
+
import { invalidateLimit } from '../../access/limits-registry'
|
|
4
|
+
import { CORE_QUERY_ENTITIES } from '../../app/core-entities'
|
|
5
|
+
import { useChatStore } from '../stores/chat.store'
|
|
6
|
+
|
|
7
|
+
/** Chat-store confirmation gate, callable from any handler. */
|
|
8
|
+
function requestChatConfirmation(action: {
|
|
9
|
+
id: string
|
|
10
|
+
toolName: string
|
|
11
|
+
title: string
|
|
12
|
+
params: Record<string, unknown>
|
|
13
|
+
plane: 'client' | 'server'
|
|
14
|
+
}): Promise<boolean> {
|
|
15
|
+
return useChatStore.getState().requestConfirmation(action)
|
|
16
|
+
}
|
|
17
|
+
import type { EntityDef, OrgMember, Organization } from '@fayz-ai/core'
|
|
18
|
+
import type { PluginAITool, PluginRegistryDef } from '../types/plugins'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Client-plane execution for the AI tool catalog.
|
|
22
|
+
*
|
|
23
|
+
* `useAITools()` declares *what* the surface can do — it is the catalog the
|
|
24
|
+
* model reasons over. This module is *how*: the browser runs the call against
|
|
25
|
+
* the session that is already open (org store, data providers, router) and
|
|
26
|
+
* hands a serialized result back to the agent.
|
|
27
|
+
*
|
|
28
|
+
* The split matters because Fayz never holds the surface's data credentials.
|
|
29
|
+
* The agent broker forwards the catalog, decides which tool to call, and
|
|
30
|
+
* returns the call for the surface to execute — the same contract a
|
|
31
|
+
* server-side channel (WhatsApp, email) satisfies with a server-side executor.
|
|
32
|
+
*
|
|
33
|
+
* **A capability is only advertised if it can run.** A declared tool with no
|
|
34
|
+
* executor is a promise to the model the surface cannot keep: it burns
|
|
35
|
+
* reasoning rounds retrying, then apologises. `buildAgentToolset` is the gate —
|
|
36
|
+
* everything it returns has a resolvable executor.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** An entity the executor can read through its own data provider. */
|
|
40
|
+
export interface DataToolTarget {
|
|
41
|
+
label: string
|
|
42
|
+
entity: EntityDef
|
|
43
|
+
mockData?: Array<{ id: string }>
|
|
44
|
+
/** Read-model (view/ledger): searchable/queryable but never writable. */
|
|
45
|
+
readOnly?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AIToolExecutionContext {
|
|
49
|
+
currentOrg: Organization | null
|
|
50
|
+
members: OrgMember[]
|
|
51
|
+
currentPath: string
|
|
52
|
+
/** Pages the surface exposes, used to resolve navigation requests. */
|
|
53
|
+
routes: Array<{ path: string; label?: string }>
|
|
54
|
+
navigate: (path: string) => void
|
|
55
|
+
/**
|
|
56
|
+
* Data-backed tools, keyed by the LLM-facing tool name. These execute
|
|
57
|
+
* generically through the entity's own data provider, so every registry and
|
|
58
|
+
* every CRUD entity the app declares is readable without a hand-written
|
|
59
|
+
* handler — which is what lets a new vertical work on day one.
|
|
60
|
+
*/
|
|
61
|
+
dataTools: Map<string, DataToolTarget>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type AIToolHandler = (
|
|
65
|
+
args: Record<string, unknown>,
|
|
66
|
+
context: AIToolExecutionContext,
|
|
67
|
+
) => unknown | Promise<unknown>
|
|
68
|
+
|
|
69
|
+
const handlers = new Map<string, AIToolHandler>()
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register an executor for a tool, keyed by the tool's `name` (the identifier
|
|
73
|
+
* the model calls, e.g. `getTeamMembers`). Plugins and apps call this to make
|
|
74
|
+
* their declared `aiTools` actually runnable.
|
|
75
|
+
*/
|
|
76
|
+
export function registerAIToolHandler(name: string, handler: AIToolHandler): void {
|
|
77
|
+
handlers.set(name, handler)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function registerAIToolHandlers(map: Record<string, AIToolHandler>): void {
|
|
81
|
+
for (const [name, handler] of Object.entries(map)) registerAIToolHandler(name, handler)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function hasAIToolHandler(name: string): boolean {
|
|
85
|
+
return handlers.has(name)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Rows returned per data call — enough to answer, cheap in tokens. */
|
|
89
|
+
const REGISTRY_PAGE_SIZE = 20
|
|
90
|
+
|
|
91
|
+
/** Beyond this the catalog costs more in tokens than it buys in capability. */
|
|
92
|
+
const MAX_TOOLS_PER_TURN = 24
|
|
93
|
+
|
|
94
|
+
export interface AIToolExecutionResult {
|
|
95
|
+
ok: boolean
|
|
96
|
+
/** JSON-serialized payload handed back to the agent. */
|
|
97
|
+
content: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function errorResult(error: string, message: string): AIToolExecutionResult {
|
|
101
|
+
return { ok: false, content: JSON.stringify({ error, message }) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The documented contract: every returned record carries a semantic ref
|
|
105
|
+
* {id, resource, archetype} the surface can resolve to ITS OWN route — never
|
|
106
|
+
* a URL (agents.md: presentation stays client knowledge). */
|
|
107
|
+
function attachRefs(
|
|
108
|
+
entity: EntityDef,
|
|
109
|
+
rows: Array<Record<string, unknown>>,
|
|
110
|
+
): Array<Record<string, unknown>> {
|
|
111
|
+
const archetype = entity.data?.archetype
|
|
112
|
+
if (!archetype) return rows
|
|
113
|
+
const kind = entity.data?.archetypeKind
|
|
114
|
+
return rows.map((r) =>
|
|
115
|
+
r.id
|
|
116
|
+
? { ...r, ref: { id: r.id, resource: entity.data?.table, archetype: kind ? `${archetype}:${kind}` : archetype } }
|
|
117
|
+
: r,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function rowContainsText(row: Record<string, unknown>, needle: string): boolean {
|
|
122
|
+
const n = needle.toLowerCase()
|
|
123
|
+
return Object.values(row).some((v) => typeof v === 'string' && v.toLowerCase().includes(n))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function executeDataTool(
|
|
127
|
+
target: DataToolTarget,
|
|
128
|
+
args: Record<string, unknown>,
|
|
129
|
+
): Promise<unknown> {
|
|
130
|
+
const provider = resolveDataProvider(
|
|
131
|
+
target.entity as EntityDef<{ id: string }>,
|
|
132
|
+
target.mockData,
|
|
133
|
+
)
|
|
134
|
+
const search = typeof args.search === 'string' && args.search.trim() ? args.search.trim() : undefined
|
|
135
|
+
const result = await provider.list({
|
|
136
|
+
search,
|
|
137
|
+
pageSize: REGISTRY_PAGE_SIZE,
|
|
138
|
+
...(typeof args.orderBy === 'string'
|
|
139
|
+
? { sortBy: args.orderBy, sortDir: args.direction === 'asc' ? 'asc' : 'desc' }
|
|
140
|
+
: {}),
|
|
141
|
+
})
|
|
142
|
+
// Honesty guard: a provider with no searchable columns quietly IGNORES
|
|
143
|
+
// `search` and returns recent rows — which the model then presents as the
|
|
144
|
+
// thing it looked for ("REC-00012 vale R$6000" when no row matched at all).
|
|
145
|
+
// If nothing in the returned rows contains the text, say so explicitly.
|
|
146
|
+
const rows = result.data as Array<Record<string, unknown>>
|
|
147
|
+
const unmatched = !!search && rows.length > 0 && !rows.some((r) => rowContainsText(r, search))
|
|
148
|
+
return {
|
|
149
|
+
entity: target.label,
|
|
150
|
+
...(search ? { search } : {}),
|
|
151
|
+
total: result.total,
|
|
152
|
+
// Truthful cap: the model is told the list was trimmed so it can say so
|
|
153
|
+
// rather than presenting one page as the whole set.
|
|
154
|
+
truncated: result.total > result.data.length,
|
|
155
|
+
records: attachRefs(target.entity, rows),
|
|
156
|
+
...(unmatched
|
|
157
|
+
? {
|
|
158
|
+
warning:
|
|
159
|
+
'None of these rows contain the search text — they are RECENT records, not matches. Do NOT present them as the searched item; say it was not found or try another entity.',
|
|
160
|
+
}
|
|
161
|
+
: {}),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Tolerant field resolution: exact key, then camelCase, then snake_case. */
|
|
166
|
+
function resolveFieldKey(row: Record<string, unknown>, name: string): string | null {
|
|
167
|
+
if (name in row) return name
|
|
168
|
+
const camel = name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
|
|
169
|
+
if (camel in row) return camel
|
|
170
|
+
const snake = name.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase())
|
|
171
|
+
if (snake in row) return snake
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRangeObject(v: unknown): v is Record<string, unknown> {
|
|
176
|
+
return (
|
|
177
|
+
typeof v === 'object' &&
|
|
178
|
+
v !== null &&
|
|
179
|
+
!Array.isArray(v) &&
|
|
180
|
+
('from' in v || 'to' in v || 'gte' in v || 'lte' in v || 'gt' in v || 'lt' in v)
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function compareInRange(raw: unknown, range: Record<string, unknown>): boolean {
|
|
185
|
+
const asNumberOrTime = (v: unknown): number => {
|
|
186
|
+
if (typeof v === 'number') return v
|
|
187
|
+
const ts = Date.parse(String(v))
|
|
188
|
+
if (!Number.isNaN(ts)) return ts
|
|
189
|
+
return Number(v)
|
|
190
|
+
}
|
|
191
|
+
const value = asNumberOrTime(raw)
|
|
192
|
+
if (Number.isNaN(value)) return false
|
|
193
|
+
const lo = range.from ?? range.gte ?? range.gt
|
|
194
|
+
const hi = range.to ?? range.lte ?? range.lt
|
|
195
|
+
if (lo !== undefined && value < asNumberOrTime(lo)) return false
|
|
196
|
+
if (lo !== undefined && 'gt' in range && value <= asNumberOrTime(lo)) return false
|
|
197
|
+
if (hi !== undefined && value > asNumberOrTime(hi)) return false
|
|
198
|
+
if (hi !== undefined && 'lt' in range && value >= asNumberOrTime(hi)) return false
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function executeAITool(
|
|
203
|
+
name: string,
|
|
204
|
+
args: Record<string, unknown>,
|
|
205
|
+
context: AIToolExecutionContext,
|
|
206
|
+
): Promise<AIToolExecutionResult> {
|
|
207
|
+
const handler = handlers.get(name)
|
|
208
|
+
const dataTarget = context.dataTools.get(name)
|
|
209
|
+
|
|
210
|
+
if (!handler && !dataTarget) {
|
|
211
|
+
return errorResult(
|
|
212
|
+
'not_executable',
|
|
213
|
+
`The tool "${name}" is not available here. Do not retry it — tell the user what you cannot do, or use a different tool.`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const result = handler
|
|
219
|
+
? await handler(args, context)
|
|
220
|
+
: await executeDataTool(dataTarget!, args)
|
|
221
|
+
return { ok: true, content: JSON.stringify(result ?? { ok: true }) }
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return errorResult(
|
|
224
|
+
'execution_failed',
|
|
225
|
+
error instanceof Error ? error.message : 'Unknown error',
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Index everything readable by the tool name its generator mints, so the
|
|
232
|
+
* executor can find the entity behind a call. Covers plugin registries and the
|
|
233
|
+
* app's own CRUD entities.
|
|
234
|
+
*/
|
|
235
|
+
export function buildDataToolIndex(input: {
|
|
236
|
+
registries: Map<string, PluginRegistryDef[]>
|
|
237
|
+
entities: Array<{ entityKey: string; labelPlural: string; entityDef?: EntityDef; mockData?: Array<{ id: string }> }>
|
|
238
|
+
registryToolName: (pluginId: string, registryId: string) => string
|
|
239
|
+
entityToolName: (entityKey: string) => string
|
|
240
|
+
queryEntities?: Array<{ key: string; entity: EntityDef; writable?: boolean }>
|
|
241
|
+
}): Map<string, DataToolTarget> {
|
|
242
|
+
const index = new Map<string, DataToolTarget>()
|
|
243
|
+
|
|
244
|
+
for (const [pluginId, defs] of input.registries) {
|
|
245
|
+
for (const registry of defs) {
|
|
246
|
+
if (registry.readOnly) continue
|
|
247
|
+
const target = {
|
|
248
|
+
label: registry.entity.namePlural ?? registry.entity.name,
|
|
249
|
+
entity: registry.entity as EntityDef,
|
|
250
|
+
mockData: registry.mockData as Array<{ id: string }> | undefined,
|
|
251
|
+
}
|
|
252
|
+
index.set(input.registryToolName(pluginId, registry.id), target)
|
|
253
|
+
// Key-indexed twin: the data primitives (searchRecords/queryData) receive
|
|
254
|
+
// the TARGET as an argument instead of minting one tool per entity.
|
|
255
|
+
index.set(`key:${pluginId}:${registry.id}`, target)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const entity of input.entities) {
|
|
260
|
+
if (!entity.entityDef || entity.entityDef.agentHidden) continue
|
|
261
|
+
const target = {
|
|
262
|
+
label: entity.labelPlural,
|
|
263
|
+
entity: entity.entityDef,
|
|
264
|
+
mockData: entity.mockData,
|
|
265
|
+
}
|
|
266
|
+
index.set(input.entityToolName(entity.entityKey), target)
|
|
267
|
+
index.set(`key:${entity.entityKey}`, target)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// CORE_QUERY_ENTITIES merged here — same reason buildDataPrimitiveTools does
|
|
271
|
+
// it: this index and that catalog MUST agree, or the model is offered an
|
|
272
|
+
// entity the executor then rejects as unknown.
|
|
273
|
+
for (const q of [...(input.queryEntities ?? []), ...CORE_QUERY_ENTITIES]) {
|
|
274
|
+
index.set(`key:${q.key}`, {
|
|
275
|
+
label: q.entity.namePlural ?? q.entity.name,
|
|
276
|
+
entity: q.entity,
|
|
277
|
+
readOnly: !q.writable,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return index
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Generic data primitives — ONE search + ONE aggregate over ANY entity. The
|
|
286
|
+
// target's own read permission (EntityDef.permission) is checked per call, so
|
|
287
|
+
// consolidating the catalog never widens access: the same role×plan engine
|
|
288
|
+
// that gated the per-entity tools gates the parameterized target.
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
function resolveKeyedTarget(
|
|
292
|
+
ctx: AIToolExecutionContext,
|
|
293
|
+
args: Record<string, unknown>,
|
|
294
|
+
): { target: DataToolTarget } | { error: unknown } {
|
|
295
|
+
const key = typeof args.entity === 'string' ? args.entity : ''
|
|
296
|
+
const target = ctx.dataTools.get(`key:${key}`)
|
|
297
|
+
if (!target) {
|
|
298
|
+
return {
|
|
299
|
+
error: {
|
|
300
|
+
error: 'unknown_entity',
|
|
301
|
+
message: `No entity "${key}" is available. Use one of the keys listed in the tool description.`,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const permission = target.entity.permission
|
|
306
|
+
if (permission) {
|
|
307
|
+
const access = checkAccess(permission.feature, permission.action)
|
|
308
|
+
if (!access.allowed) return { error: access }
|
|
309
|
+
}
|
|
310
|
+
return { target }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Field names are equal ignoring case and word separators, so a model sending
|
|
314
|
+
* `postalCode` matches a `postal_code` column and vice versa. */
|
|
315
|
+
function sameField(a: string, b: string): boolean {
|
|
316
|
+
const norm = (s: string) => s.replace(/[_-]/g, '').toLowerCase()
|
|
317
|
+
return norm(a) === norm(b)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Match a call's `values` against the target entity's declared fields,
|
|
322
|
+
* tolerating a snake_case/camelCase mismatch either way.
|
|
323
|
+
*
|
|
324
|
+
* Deliberately does NOT try to guess which other entity the caller "meant".
|
|
325
|
+
* Scoring candidates by how many of the rejected field names they declare was
|
|
326
|
+
* tried and is actively harmful here: for {address, city, postalCode, state,
|
|
327
|
+
* country} the salon app's `location` and `local-de-atendimento` both score 5
|
|
328
|
+
* against `address`'s 4, so "add an address to this contact" would be redirected
|
|
329
|
+
* into creating a business LOCATION. Field names alone cannot tell a child
|
|
330
|
+
* record from an unrelated table that happens to describe a place; the guidance
|
|
331
|
+
* that does work is declared up front, in the tool description.
|
|
332
|
+
*/
|
|
333
|
+
function matchDeclaredFields(
|
|
334
|
+
target: DataToolTarget,
|
|
335
|
+
values: Record<string, unknown>,
|
|
336
|
+
): { clean: Record<string, unknown> } | { error: Record<string, unknown> } {
|
|
337
|
+
const declaredKeys = (target.entity.fields ?? []).map((f) => f.key)
|
|
338
|
+
const clean: Record<string, unknown> = {}
|
|
339
|
+
const unknown: string[] = []
|
|
340
|
+
for (const [k, v] of Object.entries(values)) {
|
|
341
|
+
const match = declaredKeys.find((d) => sameField(d, k)) ?? null
|
|
342
|
+
if (match) clean[match] = v
|
|
343
|
+
else unknown.push(k)
|
|
344
|
+
}
|
|
345
|
+
if (!unknown.length) return { clean }
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
error: {
|
|
349
|
+
error: 'unknown_field',
|
|
350
|
+
message: `Unknown field(s) ${unknown.join(', ')} for ${target.label}. Use only the available fields and retry.`,
|
|
351
|
+
availableFields: declaredKeys,
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* For every PERSON matched by a global search, pull the records that hang off
|
|
358
|
+
* it (today: addresses) and return them grouped by entity key. Returns null
|
|
359
|
+
* when nothing is attached, so the payload stays quiet in the common case.
|
|
360
|
+
*/
|
|
361
|
+
async function resolveAttachedRecords(
|
|
362
|
+
ctx: AIToolExecutionContext,
|
|
363
|
+
matches: Array<{ key: string; records: unknown[] }>,
|
|
364
|
+
): Promise<Record<string, unknown[]> | null> {
|
|
365
|
+
const ownerIds = new Set<string>()
|
|
366
|
+
for (const m of matches) {
|
|
367
|
+
const target = ctx.dataTools.get(`key:${m.key}`)
|
|
368
|
+
if (target?.entity.data?.archetype !== 'person') continue
|
|
369
|
+
for (const r of m.records) {
|
|
370
|
+
const id = (r as { id?: unknown }).id
|
|
371
|
+
if (typeof id === 'string') ownerIds.add(id)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (!ownerIds.size) return null
|
|
375
|
+
|
|
376
|
+
const out: Record<string, unknown[]> = {}
|
|
377
|
+
for (const q of CORE_QUERY_ENTITIES) {
|
|
378
|
+
const target = ctx.dataTools.get(`key:${q.key}`)
|
|
379
|
+
if (!target) continue
|
|
380
|
+
const permission = target.entity.permission
|
|
381
|
+
if (permission && !checkAccess(permission.feature, permission.action).allowed) continue
|
|
382
|
+
try {
|
|
383
|
+
const provider = resolveDataProvider(target.entity as EntityDef<{ id: string }>, target.mockData)
|
|
384
|
+
const result = await provider.list({ pageSize: 50 })
|
|
385
|
+
const rows = (result.data as Array<Record<string, unknown>>).filter((row) => {
|
|
386
|
+
const owner = row.ownerId ?? row.owner_id
|
|
387
|
+
return typeof owner === 'string' && ownerIds.has(owner)
|
|
388
|
+
})
|
|
389
|
+
if (rows.length) out[q.key] = rows
|
|
390
|
+
} catch {
|
|
391
|
+
// An attachment lookup must never sink the search it decorates.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return Object.keys(out).length ? out : null
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
registerAIToolHandler('findAnything', async (args, ctx) => {
|
|
398
|
+
const search = typeof args.search === 'string' ? args.search.trim() : ''
|
|
399
|
+
if (!search) return { error: 'missing_search', message: 'Provide the name or text to look up.' }
|
|
400
|
+
|
|
401
|
+
// Deduped key-indexed targets, permission-filtered PER TARGET (the palette
|
|
402
|
+
// semantics: what you cannot see is not searched), person-archetypes first —
|
|
403
|
+
// "quem é X?" is a person question far more often than not.
|
|
404
|
+
const seen = new Set<DataToolTarget>()
|
|
405
|
+
const candidates: Array<{ key: string; target: DataToolTarget }> = []
|
|
406
|
+
for (const [key, target] of ctx.dataTools) {
|
|
407
|
+
if (!key.startsWith('key:') || seen.has(target)) continue
|
|
408
|
+
seen.add(target)
|
|
409
|
+
candidates.push({ key: key.slice(4), target })
|
|
410
|
+
}
|
|
411
|
+
let skippedForPermission = 0
|
|
412
|
+
const allowed = candidates.filter(({ target }) => {
|
|
413
|
+
const permission = target.entity.permission
|
|
414
|
+
if (!permission) return true
|
|
415
|
+
if (checkAccess(permission.feature, permission.action).allowed) return true
|
|
416
|
+
skippedForPermission += 1
|
|
417
|
+
return false
|
|
418
|
+
})
|
|
419
|
+
allowed.sort((a, b) => {
|
|
420
|
+
const ap = a.target.entity.data?.archetype === 'person' ? 0 : 1
|
|
421
|
+
const bp = b.target.entity.data?.archetype === 'person' ? 0 : 1
|
|
422
|
+
return ap - bp || a.key.localeCompare(b.key)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
const MAX_TARGETS = 20
|
|
426
|
+
const searched = allowed.slice(0, MAX_TARGETS)
|
|
427
|
+
const settled = await Promise.allSettled(
|
|
428
|
+
searched.map(async ({ key, target }) => {
|
|
429
|
+
const provider = resolveDataProvider(
|
|
430
|
+
target.entity as EntityDef<{ id: string }>,
|
|
431
|
+
target.mockData,
|
|
432
|
+
)
|
|
433
|
+
const result = await provider.list({ search, pageSize: 5 })
|
|
434
|
+
return {
|
|
435
|
+
key,
|
|
436
|
+
label: target.label,
|
|
437
|
+
total: result.total,
|
|
438
|
+
records: attachRefs(target.entity, result.data as Array<Record<string, unknown>>),
|
|
439
|
+
}
|
|
440
|
+
}),
|
|
441
|
+
)
|
|
442
|
+
const matches = settled
|
|
443
|
+
.filter((r) => r.status === 'fulfilled' && r.value.records.length > 0)
|
|
444
|
+
.map((r) => (r as PromiseFulfilledResult<{ key: string; label: string; total: number; records: unknown[] }>).value)
|
|
445
|
+
|
|
446
|
+
// Attached records travel WITH their owner. A person's address lives in its
|
|
447
|
+
// own table keyed by owner_id, so a text search for "Doguez" can never return
|
|
448
|
+
// it — and telling the model to make a second, filtered call is a hope, not a
|
|
449
|
+
// mechanism: it answers "this contact has no address registered" instead,
|
|
450
|
+
// which is false. Resolving them here makes the answer correct by
|
|
451
|
+
// construction. One extra query, only when a person actually matched.
|
|
452
|
+
const attached = await resolveAttachedRecords(ctx, matches)
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
query: search,
|
|
456
|
+
matches,
|
|
457
|
+
...(attached ? { attached } : {}),
|
|
458
|
+
searchedEntities: searched.length,
|
|
459
|
+
...(allowed.length > MAX_TARGETS ? { targetsTruncated: true } : {}),
|
|
460
|
+
...(skippedForPermission ? { skippedForPermission } : {}),
|
|
461
|
+
...(matches.length === 0
|
|
462
|
+
? { hint: 'No records matched anywhere the user can see. Say so — do not retry other search tools with the same text.' }
|
|
463
|
+
: {}),
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
registerAIToolHandler('createRecord', async (args, ctx) => {
|
|
468
|
+
const resolved = resolveKeyedTarget(ctx, args)
|
|
469
|
+
if ('error' in resolved) return resolved.error
|
|
470
|
+
const { target } = resolved
|
|
471
|
+
if (target.readOnly) {
|
|
472
|
+
return { error: 'read_only', message: `${target.label} is a read-only view — records cannot be created here.` }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Guard chain, in the broker's order: role×plan for CREATE → plan cap →
|
|
476
|
+
// field validation → human confirmation → write → invalidate the count.
|
|
477
|
+
const permission = target.entity.permission
|
|
478
|
+
if (permission) {
|
|
479
|
+
const access = checkAccess(permission.feature, 'create')
|
|
480
|
+
if (!access.allowed) return access
|
|
481
|
+
}
|
|
482
|
+
if (target.entity.limitKey) {
|
|
483
|
+
const limit = await guardLimit(target.entity.limitKey)
|
|
484
|
+
if (!limit.allowed) return limit
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const values = (args.values ?? {}) as Record<string, unknown>
|
|
488
|
+
const declared = target.entity.fields ?? []
|
|
489
|
+
const matched = matchDeclaredFields(target, values)
|
|
490
|
+
if ('error' in matched) return matched.error
|
|
491
|
+
const { clean } = matched
|
|
492
|
+
const missing = declared
|
|
493
|
+
.filter((f) => f.required && (clean[f.key] === undefined || clean[f.key] === null || clean[f.key] === ''))
|
|
494
|
+
.map((f) => f.key)
|
|
495
|
+
if (missing.length) {
|
|
496
|
+
return {
|
|
497
|
+
error: 'missing_required',
|
|
498
|
+
message: `Missing required field(s): ${missing.join(', ')}. Ask the user for them, then retry.`,
|
|
499
|
+
availableFields: declared.map((f) => f.key),
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Duplicate guard: same-name record already existing comes back as a
|
|
504
|
+
// structured warning FIRST — the model confirms intent with the user and
|
|
505
|
+
// retries with allowDuplicate:true only if they really want a second one.
|
|
506
|
+
const dupName = typeof clean.name === 'string' ? clean.name.trim() : ''
|
|
507
|
+
if (dupName && args.allowDuplicate !== true) {
|
|
508
|
+
const provider0 = resolveDataProvider(
|
|
509
|
+
target.entity as EntityDef<{ id: string }>,
|
|
510
|
+
target.mockData,
|
|
511
|
+
)
|
|
512
|
+
const existing = await provider0.list({ search: dupName, pageSize: 3 })
|
|
513
|
+
const dupes = (existing.data as Array<Record<string, unknown>>).filter(
|
|
514
|
+
(r) => String(r.name ?? '').trim().toLowerCase() === dupName.toLowerCase(),
|
|
515
|
+
)
|
|
516
|
+
if (dupes.length) {
|
|
517
|
+
return {
|
|
518
|
+
error: 'possible_duplicate',
|
|
519
|
+
message: `A ${target.entity.name} named "${dupName}" already exists. If the user wants to CHANGE that existing record, use updateRecord with its id; only retry createRecord with allowDuplicate:true if they explicitly want a second one.`,
|
|
520
|
+
matches: dupes,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const approved = await requestChatConfirmation({
|
|
526
|
+
id: `create-${Date.now()}`,
|
|
527
|
+
toolName: 'createRecord',
|
|
528
|
+
title: `Criar ${target.entity.name}`,
|
|
529
|
+
params: clean,
|
|
530
|
+
plane: 'client',
|
|
531
|
+
})
|
|
532
|
+
if (!approved) return { ok: false, cancelled: true, reason: 'user_declined' }
|
|
533
|
+
|
|
534
|
+
const provider = resolveDataProvider(
|
|
535
|
+
target.entity as EntityDef<{ id: string }>,
|
|
536
|
+
target.mockData,
|
|
537
|
+
)
|
|
538
|
+
const record = await provider.create(clean as never)
|
|
539
|
+
if (target.entity.limitKey) invalidateLimit(target.entity.limitKey)
|
|
540
|
+
return {
|
|
541
|
+
ok: true,
|
|
542
|
+
record,
|
|
543
|
+
ref: {
|
|
544
|
+
id: (record as { id?: string }).id,
|
|
545
|
+
label: String((record as Record<string, unknown>).name ?? target.entity.name),
|
|
546
|
+
resource: target.entity.data?.table,
|
|
547
|
+
archetype: target.entity.data?.archetype
|
|
548
|
+
? `${target.entity.data.archetype}${target.entity.data.archetypeKind ? ':' + target.entity.data.archetypeKind : ''}`
|
|
549
|
+
: undefined,
|
|
550
|
+
},
|
|
551
|
+
}
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
registerAIToolHandler('updateRecord', async (args, ctx) => {
|
|
555
|
+
const resolved = resolveKeyedTarget(ctx, args)
|
|
556
|
+
if ('error' in resolved) return resolved.error
|
|
557
|
+
const { target } = resolved
|
|
558
|
+
if (target.readOnly) {
|
|
559
|
+
return { error: 'read_only', message: `${target.label} is a read-only view — records cannot be updated here.` }
|
|
560
|
+
}
|
|
561
|
+
const id = typeof args.id === 'string' ? args.id : ''
|
|
562
|
+
if (!id) return { error: 'missing_id', message: 'Provide the id of the record to update (from this conversation or a search).' }
|
|
563
|
+
|
|
564
|
+
// Same guard chain as createRecord, with the EDIT action; updates never
|
|
565
|
+
// consume plan caps.
|
|
566
|
+
const permission = target.entity.permission
|
|
567
|
+
if (permission) {
|
|
568
|
+
const access = checkAccess(permission.feature, 'edit')
|
|
569
|
+
if (!access.allowed) return access
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const values = (args.values ?? {}) as Record<string, unknown>
|
|
573
|
+
const matched = matchDeclaredFields(target, values)
|
|
574
|
+
if ('error' in matched) return matched.error
|
|
575
|
+
const { clean } = matched
|
|
576
|
+
if (!Object.keys(clean).length) {
|
|
577
|
+
return { error: 'empty_update', message: 'No valid fields to change were provided.' }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const approved = await requestChatConfirmation({
|
|
581
|
+
id: `update-${Date.now()}`,
|
|
582
|
+
toolName: 'updateRecord',
|
|
583
|
+
title: `Atualizar ${target.entity.name}`,
|
|
584
|
+
params: { id, ...clean },
|
|
585
|
+
plane: 'client',
|
|
586
|
+
})
|
|
587
|
+
if (!approved) return { ok: false, cancelled: true, reason: 'user_declined' }
|
|
588
|
+
|
|
589
|
+
const provider = resolveDataProvider(
|
|
590
|
+
target.entity as EntityDef<{ id: string }>,
|
|
591
|
+
target.mockData,
|
|
592
|
+
)
|
|
593
|
+
const record = await provider.update(id, clean as never)
|
|
594
|
+
return {
|
|
595
|
+
ok: true,
|
|
596
|
+
record,
|
|
597
|
+
ref: {
|
|
598
|
+
id,
|
|
599
|
+
label: String((record as Record<string, unknown>).name ?? target.entity.name),
|
|
600
|
+
resource: target.entity.data?.table,
|
|
601
|
+
archetype: target.entity.data?.archetype
|
|
602
|
+
? `${target.entity.data.archetype}${target.entity.data.archetypeKind ? ':' + target.entity.data.archetypeKind : ''}`
|
|
603
|
+
: undefined,
|
|
604
|
+
},
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
registerAIToolHandler('searchRecords', async (args, ctx) => {
|
|
609
|
+
const resolved = resolveKeyedTarget(ctx, args)
|
|
610
|
+
if ('error' in resolved) return resolved.error
|
|
611
|
+
const base = (await executeDataTool(resolved.target, args)) as {
|
|
612
|
+
records: Array<Record<string, unknown>>
|
|
613
|
+
[k: string]: unknown
|
|
614
|
+
}
|
|
615
|
+
const filters = (args.filters ?? {}) as Record<string, unknown>
|
|
616
|
+
const keys = Object.keys(filters)
|
|
617
|
+
if (!keys.length) return base
|
|
618
|
+
let rows = base.records
|
|
619
|
+
for (const k of keys) {
|
|
620
|
+
const sample = rows[0] ?? base.records[0]
|
|
621
|
+
if (sample) {
|
|
622
|
+
const fieldKey = resolveFieldKey(sample, k)
|
|
623
|
+
if (!fieldKey) {
|
|
624
|
+
return {
|
|
625
|
+
error: 'unknown_field',
|
|
626
|
+
message: `Filter field "${k}" does not exist on ${resolved.target.label}. Use one of the available fields and retry.`,
|
|
627
|
+
availableFields: Object.keys(sample),
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const v = filters[k]
|
|
631
|
+
rows = isRangeObject(v)
|
|
632
|
+
? rows.filter((r) => compareInRange(r[fieldKey], v))
|
|
633
|
+
: rows.filter((r) => String(r[fieldKey] ?? '') === String(v))
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return { ...base, records: rows, filtered: keys, total: rows.length }
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
registerAIToolHandler('queryData', async (args, ctx) => {
|
|
640
|
+
const resolved = resolveKeyedTarget(ctx, args)
|
|
641
|
+
if ('error' in resolved) return resolved.error
|
|
642
|
+
const { target } = resolved
|
|
643
|
+
|
|
644
|
+
const metric = args.metric === 'sum' || args.metric === 'avg' ? args.metric : 'count'
|
|
645
|
+
let field = typeof args.field === 'string' ? args.field : undefined
|
|
646
|
+
if (metric !== 'count' && !field) {
|
|
647
|
+
return { error: 'missing_field', message: `metric "${metric}" needs a "field" to aggregate.` }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const provider = resolveDataProvider(
|
|
651
|
+
target.entity as EntityDef<{ id: string }>,
|
|
652
|
+
target.mockData,
|
|
653
|
+
)
|
|
654
|
+
// v1 samples up to 1000 rows through the provider (which owns tenant scoping
|
|
655
|
+
// and column mapping) and aggregates here; `sampled` is set when the window
|
|
656
|
+
// truncated. The S4 server-plane executor replaces this with exact SQL.
|
|
657
|
+
const page = await provider.list({ pageSize: 1000 })
|
|
658
|
+
let rows = page.data as Array<Record<string, unknown>>
|
|
659
|
+
|
|
660
|
+
// Field references FAIL LOUD: a typo'd/unknown field must never silently
|
|
661
|
+
// filter everything to zero ("Não há agendamentos" over a wrong key). The
|
|
662
|
+
// error carries the row's real keys so the model self-corrects next round.
|
|
663
|
+
const sampleRow = rows[0]
|
|
664
|
+
const availableFields = sampleRow ? Object.keys(sampleRow) : []
|
|
665
|
+
const requireField = (name: string): string | { error: string; message: string; availableFields: string[] } => {
|
|
666
|
+
if (!sampleRow) return name
|
|
667
|
+
const resolved = resolveFieldKey(sampleRow, name)
|
|
668
|
+
if (resolved) return resolved
|
|
669
|
+
return {
|
|
670
|
+
error: 'unknown_field',
|
|
671
|
+
message: `Field \"${name}\" does not exist on ${target.label}. Use one of the available fields and retry.`,
|
|
672
|
+
availableFields,
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const wantsDateRange = typeof args.from === 'string' || typeof args.to === 'string'
|
|
677
|
+
let dateKey = 'createdAt'
|
|
678
|
+
if (wantsDateRange || typeof args.dateField === 'string') {
|
|
679
|
+
const resolved = requireField(typeof args.dateField === 'string' ? args.dateField : 'createdAt')
|
|
680
|
+
if (typeof resolved !== 'string') return resolved
|
|
681
|
+
dateKey = resolved
|
|
682
|
+
}
|
|
683
|
+
const from = typeof args.from === 'string' ? Date.parse(args.from) : NaN
|
|
684
|
+
const to = typeof args.to === 'string' ? Date.parse(args.to) : NaN
|
|
685
|
+
if (!Number.isNaN(from) || !Number.isNaN(to)) {
|
|
686
|
+
rows = rows.filter((r) => {
|
|
687
|
+
const raw = r[dateKey]
|
|
688
|
+
const ts = typeof raw === 'string' || typeof raw === 'number' ? Date.parse(String(raw)) : NaN
|
|
689
|
+
if (Number.isNaN(ts)) return false
|
|
690
|
+
if (!Number.isNaN(from) && ts < from) return false
|
|
691
|
+
if (!Number.isNaN(to) && ts > to) return false
|
|
692
|
+
return true
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const filters = (args.filters ?? {}) as Record<string, unknown>
|
|
697
|
+
for (const [k, v] of Object.entries(filters)) {
|
|
698
|
+
const resolved = requireField(k)
|
|
699
|
+
if (typeof resolved !== 'string') return resolved
|
|
700
|
+
if (isRangeObject(v)) {
|
|
701
|
+
// Models naturally express ranges as {from,to} nested in filters —
|
|
702
|
+
// honor it instead of comparing against "[object Object]".
|
|
703
|
+
rows = rows.filter((r) => compareInRange(r[resolved], v))
|
|
704
|
+
} else {
|
|
705
|
+
rows = rows.filter((r) => String(r[resolved] ?? '') === String(v))
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const aggregate = (subset: Array<Record<string, unknown>>) => {
|
|
710
|
+
if (metric === 'count') return subset.length
|
|
711
|
+
const values = subset
|
|
712
|
+
.map((r) => Number(r[field!]))
|
|
713
|
+
.filter((n) => !Number.isNaN(n))
|
|
714
|
+
const sum = values.reduce((a, b) => a + b, 0)
|
|
715
|
+
if (metric === 'sum') return Math.round(sum * 100) / 100
|
|
716
|
+
return values.length ? Math.round((sum / values.length) * 100) / 100 : 0
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (field && rows[0]) {
|
|
720
|
+
const resolved = resolveFieldKey(rows[0], field)
|
|
721
|
+
if (!resolved) {
|
|
722
|
+
return {
|
|
723
|
+
error: 'unknown_field',
|
|
724
|
+
message: `Field "${field}" does not exist on ${target.label}. Use one of the available fields and retry.`,
|
|
725
|
+
availableFields,
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
field = resolved
|
|
729
|
+
}
|
|
730
|
+
let groupBy = typeof args.groupBy === 'string' ? args.groupBy : undefined
|
|
731
|
+
if (groupBy && rows[0]) {
|
|
732
|
+
const resolved = resolveFieldKey(rows[0], groupBy)
|
|
733
|
+
if (!resolved) {
|
|
734
|
+
return {
|
|
735
|
+
error: 'unknown_field',
|
|
736
|
+
message: `Field "${groupBy}" does not exist on ${target.label}. Use one of the available fields and retry.`,
|
|
737
|
+
availableFields,
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
groupBy = resolved
|
|
741
|
+
}
|
|
742
|
+
const sampled = page.total > page.data.length
|
|
743
|
+
if (groupBy) {
|
|
744
|
+
const groups = new Map<string, Array<Record<string, unknown>>>()
|
|
745
|
+
for (const r of rows) {
|
|
746
|
+
const g = String(r[groupBy] ?? '—')
|
|
747
|
+
if (!groups.has(g)) groups.set(g, [])
|
|
748
|
+
groups.get(g)!.push(r)
|
|
749
|
+
}
|
|
750
|
+
// A group key like professionalId is opaque to the user — attach the
|
|
751
|
+
// sibling display field (professionalName) when the rows carry one.
|
|
752
|
+
const labelKey = groupBy.endsWith('Id')
|
|
753
|
+
? resolveFieldKey(rows[0] ?? {}, groupBy.slice(0, -2) + 'Name')
|
|
754
|
+
: null
|
|
755
|
+
return {
|
|
756
|
+
entity: target.label,
|
|
757
|
+
metric,
|
|
758
|
+
...(field ? { field } : {}),
|
|
759
|
+
groupBy,
|
|
760
|
+
groups: Array.from(groups.entries())
|
|
761
|
+
.map(([group, subset]) => ({
|
|
762
|
+
group,
|
|
763
|
+
...(labelKey && subset[0] ? { label: subset[0][labelKey] } : {}),
|
|
764
|
+
value: aggregate(subset),
|
|
765
|
+
rows: subset.length,
|
|
766
|
+
}))
|
|
767
|
+
.sort((a, b) => Number(b.value) - Number(a.value)),
|
|
768
|
+
matchedRows: rows.length,
|
|
769
|
+
sampled,
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
entity: target.label,
|
|
774
|
+
metric,
|
|
775
|
+
...(field ? { field } : {}),
|
|
776
|
+
value: aggregate(rows),
|
|
777
|
+
matchedRows: rows.length,
|
|
778
|
+
sampled,
|
|
779
|
+
}
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
export interface AgentTool {
|
|
783
|
+
name: string
|
|
784
|
+
description: string
|
|
785
|
+
parameters?: Record<string, unknown>
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export interface BuildToolsetOptions {
|
|
789
|
+
dataTools: Map<string, DataToolTarget>
|
|
790
|
+
/** Plugin owning the current route; its tools rank ahead of the rest. */
|
|
791
|
+
activePluginId?: string | null
|
|
792
|
+
maxTools?: number
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Shape the declared catalog into the toolset sent to the agent.
|
|
797
|
+
*
|
|
798
|
+
* Two filters, both load-bearing:
|
|
799
|
+
*
|
|
800
|
+
* 1. *Executable only.* An advertised tool with no executor is worse than a
|
|
801
|
+
* missing one — the model spends rounds on it and then apologises.
|
|
802
|
+
* 2. *Contextual.* A mature app declares ~40 tools; sending all of them every
|
|
803
|
+
* turn costs input tokens on every request and degrades which tool the model
|
|
804
|
+
* picks. Tools owned by the plugin the user is currently looking at come
|
|
805
|
+
* first, then the rest, capped.
|
|
806
|
+
*/
|
|
807
|
+
export function buildAgentToolset(
|
|
808
|
+
tools: PluginAITool[],
|
|
809
|
+
{ dataTools, activePluginId, maxTools = MAX_TOOLS_PER_TURN }: BuildToolsetOptions,
|
|
810
|
+
): AgentTool[] {
|
|
811
|
+
const executable = tools.filter(
|
|
812
|
+
(t) =>
|
|
813
|
+
handlers.has(t.name) ||
|
|
814
|
+
dataTools.has(t.name) ||
|
|
815
|
+
(t.execution?.plane === 'server' && t.execution.kind === 'rpc'),
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
const ranked = activePluginId
|
|
819
|
+
? [
|
|
820
|
+
...executable.filter((t) => t.id.startsWith(`${activePluginId}.`)),
|
|
821
|
+
...executable.filter((t) => !t.id.startsWith(`${activePluginId}.`)),
|
|
822
|
+
]
|
|
823
|
+
: executable
|
|
824
|
+
|
|
825
|
+
return ranked.slice(0, maxTools).map((tool) => ({
|
|
826
|
+
name: tool.name,
|
|
827
|
+
description: tool.description,
|
|
828
|
+
parameters: tool.parameters as Record<string, unknown> | undefined,
|
|
829
|
+
}))
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
// Core handlers — the executable counterpart of `coreAITools`.
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
|
|
836
|
+
registerAIToolHandler('getBusinessSummary', (_args, ctx) => {
|
|
837
|
+
if (!ctx.currentOrg) return { error: 'no_active_business' }
|
|
838
|
+
const org = ctx.currentOrg as Organization & { plan?: string; verticalId?: string }
|
|
839
|
+
return {
|
|
840
|
+
name: org.name,
|
|
841
|
+
slug: org.slug,
|
|
842
|
+
plan: org.plan ?? null,
|
|
843
|
+
vertical: org.verticalId ?? null,
|
|
844
|
+
teamSize: ctx.members.length,
|
|
845
|
+
createdAt: org.createdAt ?? null,
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
registerAIToolHandler('getTeamMembers', (args, ctx) => {
|
|
850
|
+
const role = typeof args.role === 'string' ? args.role.toLowerCase() : undefined
|
|
851
|
+
const members = ctx.members
|
|
852
|
+
.map((m) => m as OrgMember & { role?: string; status?: string })
|
|
853
|
+
.filter((m) => !role || String(m.role ?? '').toLowerCase() === role)
|
|
854
|
+
.map((m) => ({
|
|
855
|
+
name: m.profileName ?? m.user?.fullName ?? null,
|
|
856
|
+
email: m.user?.email ?? null,
|
|
857
|
+
role: m.role ?? null,
|
|
858
|
+
status: m.status ?? null,
|
|
859
|
+
}))
|
|
860
|
+
return { total: members.length, members }
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
registerAIToolHandler('navigateTo', (args, ctx) => {
|
|
864
|
+
const query = typeof args.page === 'string' ? args.page.trim().toLowerCase() : ''
|
|
865
|
+
if (!query) return { error: 'missing_page' }
|
|
866
|
+
|
|
867
|
+
const match =
|
|
868
|
+
ctx.routes.find((r) => r.path.toLowerCase() === query || r.path.toLowerCase() === `/${query}`) ??
|
|
869
|
+
ctx.routes.find((r) => (r.label ?? '').toLowerCase() === query) ??
|
|
870
|
+
ctx.routes.find((r) => r.path.toLowerCase().includes(query) || (r.label ?? '').toLowerCase().includes(query))
|
|
871
|
+
|
|
872
|
+
if (!match) {
|
|
873
|
+
return {
|
|
874
|
+
error: 'page_not_found',
|
|
875
|
+
availablePages: ctx.routes.map((r) => r.label ?? r.path),
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
ctx.navigate(match.path)
|
|
880
|
+
return { navigatedTo: match.path, label: match.label ?? match.path }
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Client-plane execution of an RPC-bound tool (`execution: {kind:'rpc'}`):
|
|
886
|
+
* calls the pool function as the signed-in user. The actor is the SESSION
|
|
887
|
+
* user — spine 016 makes the RPC reject any other p_actor for JWT callers, so
|
|
888
|
+
* this path cannot borrow someone else's role. Structured denials pass through
|
|
889
|
+
* to the model (conversational paywall); S4 moves this server-side untouched.
|
|
890
|
+
*/
|
|
891
|
+
export async function executeRpcTool(
|
|
892
|
+
rpcName: string,
|
|
893
|
+
args: Record<string, unknown>,
|
|
894
|
+
userId: string | undefined,
|
|
895
|
+
): Promise<AIToolExecutionResult> {
|
|
896
|
+
const supabase = getSupabaseClientOptional() as {
|
|
897
|
+
rpc?: (fn: string, params: Record<string, unknown>) => Promise<{ data: unknown; error: { message: string } | null }>
|
|
898
|
+
} | null
|
|
899
|
+
const tenantId = getActiveTenantId()
|
|
900
|
+
if (!supabase?.rpc || !tenantId || !userId) {
|
|
901
|
+
return errorResult('not_executable', 'No authenticated session/tenant available for this action.')
|
|
902
|
+
}
|
|
903
|
+
const { data, error } = await supabase.rpc(rpcName, {
|
|
904
|
+
p_tenant_id: tenantId,
|
|
905
|
+
p_actor_user_id: userId,
|
|
906
|
+
p_payload: args,
|
|
907
|
+
})
|
|
908
|
+
if (error) return errorResult('execution_failed', error.message)
|
|
909
|
+
return { ok: true, content: JSON.stringify(data ?? { ok: true }) }
|
|
910
|
+
}
|