@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.
Files changed (236) hide show
  1. package/dist/access/context.d.ts +47 -0
  2. package/dist/access/context.d.ts.map +1 -0
  3. package/dist/access/headless.d.ts +21 -0
  4. package/dist/access/headless.d.ts.map +1 -0
  5. package/dist/access/index.d.ts +12 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/limits-registry.d.ts +33 -0
  8. package/dist/access/limits-registry.d.ts.map +1 -0
  9. package/dist/access/module-nav.d.ts +15 -0
  10. package/dist/access/module-nav.d.ts.map +1 -0
  11. package/dist/access/resolver.d.ts +2 -0
  12. package/dist/access/resolver.d.ts.map +1 -0
  13. package/dist/access/types.d.ts +43 -0
  14. package/dist/access/types.d.ts.map +1 -0
  15. package/dist/access/upgrade-modal-store.d.ts +23 -0
  16. package/dist/access/upgrade-modal-store.d.ts.map +1 -0
  17. package/dist/app/AdminShell.d.ts +3 -0
  18. package/dist/app/AdminShell.d.ts.map +1 -1
  19. package/dist/app/admin-app.d.ts +3 -1
  20. package/dist/app/admin-app.d.ts.map +1 -1
  21. package/dist/app/config.d.ts +47 -2
  22. package/dist/app/config.d.ts.map +1 -1
  23. package/dist/app/core-entities.d.ts +25 -0
  24. package/dist/app/core-entities.d.ts.map +1 -0
  25. package/dist/app/derive-contract.d.ts +8 -0
  26. package/dist/app/derive-contract.d.ts.map +1 -0
  27. package/dist/app/scaffold.d.ts.map +1 -1
  28. package/dist/archetype-lookup.d.ts.map +1 -1
  29. package/dist/billing/index.cjs +6 -2
  30. package/dist/billing/index.d.ts +2 -0
  31. package/dist/billing/index.d.ts.map +1 -1
  32. package/dist/billing/index.js +1 -1
  33. package/dist/billing/plan-badge.d.ts +17 -0
  34. package/dist/billing/plan-badge.d.ts.map +1 -0
  35. package/dist/billing/store.d.ts +6 -1
  36. package/dist/billing/store.d.ts.map +1 -1
  37. package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
  38. package/dist/chunk-I3DY47N5.js.map +1 -0
  39. package/dist/chunk-NNFGMUF2.cjs +39 -0
  40. package/dist/chunk-NNFGMUF2.cjs.map +1 -0
  41. package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
  42. package/dist/chunk-SAZLW54Y.cjs.map +1 -0
  43. package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
  44. package/dist/chunk-VKYZACG5.cjs.map +1 -0
  45. package/dist/chunk-VN5NTK26.js +36 -0
  46. package/dist/chunk-VN5NTK26.js.map +1 -0
  47. package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
  48. package/dist/chunk-VTEP2AQX.js.map +1 -0
  49. package/dist/components/shared/ContactPicker.d.ts +92 -0
  50. package/dist/components/shared/ContactPicker.d.ts.map +1 -0
  51. package/dist/components/shared/PersonLink.d.ts +12 -1
  52. package/dist/components/shared/PersonLink.d.ts.map +1 -1
  53. package/dist/components/shared/create-person.d.ts +20 -0
  54. package/dist/components/shared/create-person.d.ts.map +1 -0
  55. package/dist/crud/CrudDetailPage.d.ts.map +1 -1
  56. package/dist/crud/CrudFormPage.d.ts.map +1 -1
  57. package/dist/crud/CrudListView.d.ts.map +1 -1
  58. package/dist/crud/CrudPage.d.ts.map +1 -1
  59. package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
  60. package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
  61. package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
  62. package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
  63. package/dist/crud/createCrudPage.d.ts.map +1 -1
  64. package/dist/crud/index.d.ts +1 -0
  65. package/dist/crud/index.d.ts.map +1 -1
  66. package/dist/index.cjs +4300 -722
  67. package/dist/index.cjs.map +1 -1
  68. package/dist/index.d.ts +17 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4080 -542
  71. package/dist/index.js.map +1 -1
  72. package/dist/org/index.cjs +11 -11
  73. package/dist/org/index.js +2 -2
  74. package/dist/org/store.d.ts.map +1 -1
  75. package/dist/permissions/context.d.ts +2 -0
  76. package/dist/permissions/context.d.ts.map +1 -1
  77. package/dist/permissions/index.cjs +7 -7
  78. package/dist/permissions/index.js +1 -1
  79. package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
  80. package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
  81. package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
  82. package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
  83. package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
  84. package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
  85. package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
  86. package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
  87. package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
  88. package/dist/shell/components/billing/access-contract.d.ts +18 -0
  89. package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
  90. package/dist/shell/components/billing/gates.d.ts +14 -0
  91. package/dist/shell/components/billing/gates.d.ts.map +1 -0
  92. package/dist/shell/components/billing/index.d.ts +9 -2
  93. package/dist/shell/components/billing/index.d.ts.map +1 -1
  94. package/dist/shell/components/chat/ChatFab.d.ts +3 -1
  95. package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
  96. package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
  97. package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
  98. package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
  99. package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
  100. package/dist/shell/components/chat/markdown.d.ts +5 -0
  101. package/dist/shell/components/chat/markdown.d.ts.map +1 -0
  102. package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
  103. package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
  104. package/dist/shell/components/organization/index.d.ts +0 -1
  105. package/dist/shell/components/organization/index.d.ts.map +1 -1
  106. package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
  107. package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
  108. package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
  109. package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
  110. package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
  111. package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
  112. package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
  113. package/dist/shell/hooks/index.d.ts +3 -2
  114. package/dist/shell/hooks/index.d.ts.map +1 -1
  115. package/dist/shell/hooks/useAITools.d.ts +2 -0
  116. package/dist/shell/hooks/useAITools.d.ts.map +1 -1
  117. package/dist/shell/hooks/useChat.d.ts +22 -0
  118. package/dist/shell/hooks/useChat.d.ts.map +1 -1
  119. package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
  120. package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
  121. package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
  122. package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
  123. package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
  124. package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
  125. package/dist/shell/lib/core-ai-tools.d.ts +14 -0
  126. package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
  127. package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
  128. package/dist/shell/lib/fayz-agent.d.ts +23 -0
  129. package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
  130. package/dist/shell/lib/i18n.d.ts.map +1 -1
  131. package/dist/shell/stores/chat.store.d.ts +69 -0
  132. package/dist/shell/stores/chat.store.d.ts.map +1 -1
  133. package/dist/shell/types/billing.d.ts +7 -0
  134. package/dist/shell/types/billing.d.ts.map +1 -1
  135. package/dist/shell/types/crud.d.ts +1 -1
  136. package/dist/shell/types/crud.d.ts.map +1 -1
  137. package/dist/shell/types/plugins.d.ts +2 -45
  138. package/dist/shell/types/plugins.d.ts.map +1 -1
  139. package/dist/stores/createCrudStore.d.ts.map +1 -1
  140. package/package.json +15 -12
  141. package/src/access/context.tsx +228 -0
  142. package/src/access/headless.ts +71 -0
  143. package/src/access/index.ts +27 -0
  144. package/src/access/limits-registry.ts +86 -0
  145. package/src/access/module-nav.test.ts +78 -0
  146. package/src/access/module-nav.ts +82 -0
  147. package/src/access/resolver.test.ts +143 -0
  148. package/src/access/resolver.ts +5 -0
  149. package/src/access/types.ts +47 -0
  150. package/src/access/upgrade-modal-store.ts +30 -0
  151. package/src/app/AdminShell.tsx +451 -68
  152. package/src/app/admin-app.tsx +46 -9
  153. package/src/app/config.ts +54 -1
  154. package/src/app/core-entities.ts +110 -0
  155. package/src/app/derive-contract.test.ts +155 -0
  156. package/src/app/derive-contract.ts +214 -0
  157. package/src/app/scaffold.tsx +4 -0
  158. package/src/archetype-lookup.ts +8 -2
  159. package/src/billing/index.ts +2 -0
  160. package/src/billing/plan-badge.ts +32 -0
  161. package/src/billing/store.ts +8 -1
  162. package/src/components/shared/ContactPicker.tsx +394 -0
  163. package/src/components/shared/PersonLink.tsx +11 -3
  164. package/src/components/shared/create-person.ts +74 -0
  165. package/src/crud/CrudDetailPage.tsx +8 -1
  166. package/src/crud/CrudFormPage.tsx +124 -6
  167. package/src/crud/CrudListView.tsx +16 -1
  168. package/src/crud/CrudPage.tsx +27 -5
  169. package/src/crud/DeleteConfirmDialog.tsx +1 -1
  170. package/src/crud/archetypes/AddressesTab.tsx +387 -0
  171. package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
  172. package/src/crud/createCrudPage.tsx +2 -0
  173. package/src/crud/index.ts +6 -0
  174. package/src/env.d.ts +7 -0
  175. package/src/index.ts +49 -1
  176. package/src/org/store.ts +9 -1
  177. package/src/permissions/context.tsx +29 -15
  178. package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
  179. package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
  180. package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
  181. package/src/shell/components/billing/UpgradeModal.tsx +102 -0
  182. package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
  183. package/src/shell/components/billing/access-contract.ts +64 -0
  184. package/src/shell/components/billing/gates.tsx +62 -0
  185. package/src/shell/components/billing/index.ts +18 -2
  186. package/src/shell/components/chat/ChatFab.tsx +4 -2
  187. package/src/shell/components/chat/ChatPanel.tsx +174 -23
  188. package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
  189. package/src/shell/components/chat/markdown.tsx +102 -0
  190. package/src/shell/components/organization/TeamTab.tsx +21 -4
  191. package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
  192. package/src/shell/components/organization/index.ts +0 -1
  193. package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
  194. package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
  195. package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
  196. package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
  197. package/src/shell/components/settings/SettingsPage.tsx +63 -21
  198. package/src/shell/hooks/index.ts +3 -2
  199. package/src/shell/hooks/useAITools.ts +27 -23
  200. package/src/shell/hooks/useChat.ts +381 -16
  201. package/src/shell/hooks/useSystemPermission.ts +23 -0
  202. package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
  203. package/src/shell/lib/ai-tool-handlers.ts +910 -0
  204. package/src/shell/lib/core-ai-tools.ts +234 -2
  205. package/src/shell/lib/create-client-orders-provider.ts +24 -2
  206. package/src/shell/lib/fayz-agent.ts +56 -0
  207. package/src/shell/lib/i18n.ts +71 -0
  208. package/src/shell/stores/chat.store.ts +118 -1
  209. package/src/shell/styles.css +30 -0
  210. package/src/shell/types/billing.ts +8 -0
  211. package/src/shell/types/crud.ts +1 -1
  212. package/src/shell/types/plugins.ts +13 -50
  213. package/src/stores/createCrudStore.ts +9 -0
  214. package/dist/chunk-3M25GRRU.js.map +0 -1
  215. package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
  216. package/dist/chunk-5GYQK5IW.js +0 -21
  217. package/dist/chunk-5GYQK5IW.js.map +0 -1
  218. package/dist/chunk-IPWCHJPE.cjs.map +0 -1
  219. package/dist/chunk-KYID2MR6.cjs +0 -23
  220. package/dist/chunk-KYID2MR6.cjs.map +0 -1
  221. package/dist/chunk-XNWCGJYH.js.map +0 -1
  222. package/dist/shell/components/billing/BillingPage.d.ts +0 -7
  223. package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
  224. package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
  225. package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
  226. package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
  227. package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
  228. package/dist/shell/hooks/useBilling.d.ts +0 -14
  229. package/dist/shell/hooks/useBilling.d.ts.map +0 -1
  230. package/dist/shell/hooks/usePermission.d.ts +0 -16
  231. package/dist/shell/hooks/usePermission.d.ts.map +0 -1
  232. package/src/shell/components/billing/BillingPage.tsx +0 -56
  233. package/src/shell/components/billing/PaywallGate.tsx +0 -80
  234. package/src/shell/components/organization/PermissionGate.tsx +0 -16
  235. package/src/shell/hooks/useBilling.ts +0 -151
  236. package/src/shell/hooks/usePermission.ts +0 -101
@@ -1,13 +1,311 @@
1
- import { useCallback } from 'react'
1
+ import { useCallback, useMemo } from 'react'
2
2
  import { useChatStore, type ChatMessage } from '../stores/chat.store'
3
+ import { useOrganizationStore } from '../stores/organization.store'
4
+ // The REAL auth store — @fayz-ai/auth is what AuthProvider hydrates (and the
5
+ // same specifier AdminShell uses). shell/stores/auth.store is a legacy shim
6
+ // nothing fills; reading it here sent EVERY turn anonymous, which an INTERNAL
7
+ // channel rejects with AGENT_IDENTITY_REQUIRED (documented gotcha in
8
+ // fayz/docs/architecture/platform/agents.md — the fix had only landed in the
9
+ // retired -qa checkout).
10
+ import { useAuthStore } from '@fayz-ai/auth'
11
+ import { usePluginRuntimeOptional } from '../lib/plugins'
12
+ import { useRouter } from '../lib/router'
13
+ import { useTranslation } from './useTranslation'
14
+ import { useAITools } from './useAITools'
15
+ import {
16
+ buildAgentToolset,
17
+ buildDataToolIndex,
18
+ executeAITool,
19
+ executeRpcTool,
20
+ type AIToolExecutionContext,
21
+ } from '../lib/ai-tool-handlers'
22
+ import { entityToolName, registryToolName } from '../lib/core-ai-tools'
23
+ import { getAllEntities, useManifest, type PluginAITool } from '@fayz-ai/core'
24
+ import { checkAccess, guardLimit } from '../../access/headless'
25
+ import { invalidateLimit } from '../../access/limits-registry'
26
+ import {
27
+ getFayzAgentClient,
28
+ resolveFayzAgentConnection,
29
+ type FayzAgentConnectionConfig,
30
+ } from '../lib/fayz-agent'
31
+ import type { FayzAgentToolResult } from '@fayz-ai/sdk/agent'
32
+
33
+ /**
34
+ * Caps how many times the model may call tools before it must answer in prose.
35
+ * Each round is a network turn, so a runaway loop is both slow and expensive.
36
+ */
37
+ const MAX_TOOL_ROUNDS = 4
3
38
 
4
39
  interface UseChatOptions {
40
+ /** Bring-your-own backend. Takes precedence over the Fayz agent connection. */
5
41
  apiEndpoint?: string
6
42
  systemPrompt?: string
43
+ /** Fayz agent overrides; defaults to the env the container injects. */
44
+ agent?: FayzAgentConnectionConfig | false
7
45
  }
8
46
 
9
47
  export function useChat(options?: UseChatOptions) {
10
48
  const store = useChatStore()
49
+ const runtime = usePluginRuntimeOptional()
50
+ const router = useRouter()
51
+ const { t } = useTranslation()
52
+ const { tools, activePluginId } = useAITools()
53
+ const currentOrg = useOrganizationStore((s) => s.currentOrg)
54
+ const members = useOrganizationStore((s) => s.members)
55
+ const user = useAuthStore((s) => s.user)
56
+
57
+ // The project's Fayz agent, resolved from the env the container injects. An
58
+ // app-supplied apiEndpoint wins — that is an explicit opt-out.
59
+ const connection = useMemo(
60
+ () => (options?.apiEndpoint ? null : resolveFayzAgentConnection(options?.agent)),
61
+ [options?.apiEndpoint, options?.agent],
62
+ )
63
+
64
+ // The assistant is only "real" when something backs it. Without a backend we
65
+ // never fake a canned demo reply — the panel disables free-text input and, if
66
+ // a suggestion chip is clicked anyway, we answer with a clear notice.
67
+ const isConfigured = !!connection || !!options?.apiEndpoint
68
+
69
+ /**
70
+ * Run the agent turn loop against the project's Fayz agent.
71
+ *
72
+ * This is the follow-up the transport-only version of this hook described:
73
+ * the tool catalog plugins declare (useAITools) is already JSON-Schema shaped,
74
+ * so it forwards verbatim. Execution stays *client-plane* — the browser runs
75
+ * each call against the session that is already open and hands results back —
76
+ * because that is the only side that can touch the running UI (navigateTo) or
77
+ * the tenant's own data session, and it keeps Fayz free of app credentials.
78
+ * Server-plane tools (AgentTool grants) execute inside Fayz and never surface
79
+ * here.
80
+ */
81
+ const manifest = useManifest()
82
+ const serverPlane = manifest?.agent?.executionPlane === 'server'
83
+
84
+ /**
85
+ * Client-plane guard around a persist tool, mirroring the broker's
86
+ * server-plane order: role→plan (checkAccess) → cap (guardLimit) → human
87
+ * confirmation → execute → invalidate the consumed limit. A denial comes
88
+ * back to the model as the structured AgentDenial, so the paywall/permission
89
+ * explanation happens IN the conversation — never a silent failure
90
+ * (docs/ENTITLEMENTS.md § agentes).
91
+ */
92
+ const executeGuarded = useCallback(
93
+ async (
94
+ call: { id: string; name: string; arguments: Record<string, unknown> },
95
+ def: PluginAITool | undefined,
96
+ toolContext: AIToolExecutionContext,
97
+ ): Promise<string> => {
98
+ if (def?.mode === 'persist') {
99
+ if (def.permission) {
100
+ const access = checkAccess(def.permission.feature, def.permission.action)
101
+ if (!access.allowed) return JSON.stringify(access)
102
+ }
103
+ if (def.limitKey) {
104
+ const limit = await guardLimit(def.limitKey)
105
+ if (!limit.allowed) return JSON.stringify(limit)
106
+ }
107
+ if (def.requiresConfirmation !== false) {
108
+ const approved = await useChatStore.getState().requestConfirmation({
109
+ id: call.id,
110
+ toolName: call.name,
111
+ title: def.description,
112
+ params: call.arguments,
113
+ plane: 'client',
114
+ })
115
+ if (!approved) {
116
+ return JSON.stringify({ ok: false, cancelled: true, reason: 'user_declined' })
117
+ }
118
+ }
119
+ }
120
+ const exec = def?.execution
121
+ const rpcName = exec && exec.plane === 'server' && exec.kind === 'rpc' ? exec.rpc : null
122
+ const result = rpcName
123
+ ? await executeRpcTool(rpcName, call.arguments, useAuthStore.getState().user?.id)
124
+ : await executeAITool(call.name, call.arguments, toolContext)
125
+ if (def?.mode === 'persist' && def.limitKey && result.ok) invalidateLimit(def.limitKey)
126
+ return result.content
127
+ },
128
+ [],
129
+ )
130
+
131
+ const runFayzAgentTurn = useCallback(
132
+ async (content: string, activeConnection: NonNullable<typeof connection>) => {
133
+ const dataTools = buildDataToolIndex({
134
+ registries: runtime?.registries ?? new Map(),
135
+ entities: getAllEntities(),
136
+ registryToolName,
137
+ entityToolName,
138
+ queryEntities: runtime?.activePlugins.flatMap((p) => p.queryEntities ?? []) ?? [],
139
+ })
140
+ const toolContext: AIToolExecutionContext = {
141
+ currentOrg,
142
+ members,
143
+ currentPath: runtime?.context.currentPath ?? '/',
144
+ routes: (runtime?.navigation ?? []).map((n) => ({ path: n.route, label: n.label })),
145
+ navigate: router.navigate,
146
+ dataTools,
147
+ }
148
+ // Server-plane flip (manifest agent.executionPlane): data/RPC tools stop
149
+ // shipping from the surface — the broker executes them from the synced
150
+ // contract; only genuinely client-plane tools still go up per request.
151
+ const clientTools = serverPlane
152
+ ? tools.filter((t) => t.execution?.plane !== 'server')
153
+ : tools
154
+ const toolset = buildAgentToolset(clientTools, { dataTools, activePluginId })
155
+ const toolByName = new Map(tools.map((t) => [t.name, t]))
156
+
157
+ const client = getFayzAgentClient(activeConnection)
158
+ // Small models are bad at weekday math — "próxima segunda" booked a
159
+ // Friday. Hand them the next 7 dates, solved.
160
+ const upcoming = Array.from({ length: 7 }, (_, i) => {
161
+ const d = new Date(Date.now() + (i + 1) * 86400e3)
162
+ return `${d.toLocaleDateString('pt-BR', { weekday: 'long' })}=${d.toISOString().slice(0, 10)}`
163
+ }).join(', ')
164
+ // Identical repeated calls burn rounds without new information — cut the
165
+ // loop with an explicit steering result instead of re-executing.
166
+ const seenCalls = new Map<string, string>()
167
+ // Goto buttons: refs surface from write results (always) and from
168
+ // single-match reads — the records the reply is ABOUT.
169
+ const collectRecordLinks = (content: string) => {
170
+ try {
171
+ const parsed = JSON.parse(content) as Record<string, unknown>
172
+ const links: Array<{ id: string; label: string; archetype?: string; kind?: string }> = []
173
+ const pushRef = (ref: unknown, fallbackLabel?: unknown) => {
174
+ if (!ref || typeof ref !== 'object') return
175
+ const r = ref as { id?: string; label?: string; archetype?: string }
176
+ if (!r.id) return
177
+ const [archetype, kind] = (r.archetype ?? '').split(':')
178
+ links.push({
179
+ id: r.id,
180
+ label: String(r.label ?? fallbackLabel ?? 'registro'),
181
+ ...(archetype ? { archetype } : {}),
182
+ ...(kind ? { kind } : {}),
183
+ })
184
+ }
185
+ pushRef(parsed.ref, (parsed.record as Record<string, unknown> | undefined)?.name)
186
+ pushRef(
187
+ (parsed.record as Record<string, unknown> | undefined)?.ref,
188
+ (parsed.record as Record<string, unknown> | undefined)?.client_name ??
189
+ (parsed.record as Record<string, unknown> | undefined)?.service_name,
190
+ )
191
+ const singleFrom = (rows: unknown) => {
192
+ if (Array.isArray(rows) && rows.length === 1) {
193
+ const row = rows[0] as Record<string, unknown>
194
+ pushRef(row.ref, row.name)
195
+ }
196
+ }
197
+ singleFrom(parsed.records)
198
+ for (const m of Array.isArray(parsed.matches) ? parsed.matches : []) {
199
+ singleFrom((m as Record<string, unknown>).records)
200
+ }
201
+ if (links.length) useChatStore.getState().appendLinksToLastAssistant(links)
202
+ } catch {
203
+ /* non-JSON tool result — nothing to link */
204
+ }
205
+ }
206
+ let toolResults: FayzAgentToolResult[] | undefined
207
+ let confirmAction: { id: string; approved: boolean } | undefined
208
+ let message: string | undefined = content
209
+
210
+ for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
211
+ const response = await client.chat({
212
+ message,
213
+ toolResults,
214
+ confirmAction,
215
+ // Attribution: an INTERNAL channel rejects turns with no identity, and
216
+ // without this every conversation lands in the owner's dashboard as
217
+ // "Anonymous" even though a signed-in user sent it.
218
+ externalUserId: user?.id,
219
+ externalUserName: user?.fullName || user?.email,
220
+ conversationId: useChatStore.getState().conversationId ?? undefined,
221
+ tools: toolset,
222
+ context: {
223
+ // Temporal grounding. Without an explicit clock the model resolves
224
+ // "tomorrow" against its training cutoff — it booked an appointment
225
+ // in 2023 before this was sent. The browser is the only side that
226
+ // knows the user's actual zone.
227
+ // Weekday spelled out: small models booked "próxima segunda" on a
228
+ // Friday because nothing said what weekday `now` is.
229
+ now: `${new Date().toISOString()} (${new Date().toLocaleDateString('pt-BR', { weekday: 'long' })})`,
230
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
231
+ locale: typeof navigator !== 'undefined' ? navigator.language : undefined,
232
+ currentPath: toolContext.currentPath,
233
+ // What this app actually contains. Derived from the live navigation
234
+ // rather than written into each agent, so an assistant knows its own
235
+ // product on day one in any vertical — and can answer "where do I
236
+ // add a professional?" instead of guessing at a generic answer.
237
+ pages: toolContext.routes.map((r) => r.label ?? r.path).join(', '),
238
+ upcomingDates: upcoming,
239
+ toolHints:
240
+ 'When an id (client, service, record) was already returned by a tool earlier in THIS conversation, REUSE that id directly — do not search for it again.',
241
+ timeNote:
242
+ 'Datetimes in tool results are UTC unless the field is *_local. The business timezone is the timeZone above — ALWAYS convert to it before telling the user a time.',
243
+ businessName: currentOrg?.name,
244
+ userName: user?.fullName || user?.email,
245
+ ...(options?.systemPrompt ? { appGuidance: options.systemPrompt } : {}),
246
+ },
247
+ })
248
+ store.setConversationId(response.conversationId)
249
+
250
+ if (response.content) store.updateLastAssistant(response.content)
251
+
252
+ // Server-parked write: the broker executed nothing and is waiting for a
253
+ // human decision. Surface the card, send the decision back, continue.
254
+ if (response.pendingAction) {
255
+ const approved = await useChatStore.getState().requestConfirmation({
256
+ id: response.pendingAction.id,
257
+ toolName: response.pendingAction.toolName,
258
+ title: response.pendingAction.title ?? response.pendingAction.toolName,
259
+ params: response.pendingAction.params ?? {},
260
+ plane: 'server',
261
+ })
262
+ confirmAction = { id: response.pendingAction.id, approved }
263
+ message = undefined
264
+ toolResults = undefined
265
+ continue
266
+ }
267
+ confirmAction = undefined
268
+
269
+ if (response.toolCalls.length === 0) return
270
+
271
+ // Last allowed round still asked for tools — stop looping and let
272
+ // whatever prose came back stand.
273
+ if (round === MAX_TOOL_ROUNDS) {
274
+ if (!response.content) {
275
+ store.updateLastAssistant('That took too many steps. Could you narrow the question?')
276
+ }
277
+ return
278
+ }
279
+
280
+ // Live activity: surface WHICH tools are running while they run — the
281
+ // agent's work should be visible, not a silent pause.
282
+ useChatStore.getState().setActiveTools(response.toolCalls.map((c) => c.name))
283
+ try {
284
+ toolResults = await Promise.all(
285
+ response.toolCalls.map(async (call) => ({
286
+ toolCallId: call.id,
287
+ content: await executeGuarded(call, toolByName.get(call.name), toolContext),
288
+ })),
289
+ )
290
+ // Persistent, expandable trace: name + args + (truncated) result per
291
+ // call, attached to the reply being built.
292
+ useChatStore.getState().appendToolCallsToLastAssistant(
293
+ response.toolCalls.map((call, i) => ({
294
+ name: call.name,
295
+ args: Object.keys(call.arguments ?? {}).length
296
+ ? JSON.stringify(call.arguments, null, 1)
297
+ : '',
298
+ result: (toolResults?.[i]?.content ?? '').slice(0, 2000),
299
+ })),
300
+ )
301
+ } finally {
302
+ useChatStore.getState().setActiveTools([])
303
+ }
304
+ message = undefined
305
+ }
306
+ },
307
+ [store, tools, serverPlane, executeGuarded, activePluginId, runtime, router, currentOrg, members, user, options?.systemPrompt],
308
+ )
11
309
 
12
310
  const sendMessage = useCallback(
13
311
  async (content: string) => {
@@ -19,6 +317,17 @@ export function useChat(options?: UseChatOptions) {
19
317
  }
20
318
  store.addMessage(userMsg)
21
319
 
320
+ if (!isConfigured) {
321
+ // Honest non-configured state — no fake "demo response".
322
+ store.addMessage({
323
+ id: `msg-${Date.now() + 1}`,
324
+ role: 'assistant',
325
+ content: t('chat.notConfigured'),
326
+ timestamp: new Date().toISOString(),
327
+ })
328
+ return
329
+ }
330
+
22
331
  const assistantMsg: ChatMessage = {
23
332
  id: `msg-${Date.now() + 1}`,
24
333
  role: 'assistant',
@@ -28,24 +337,18 @@ export function useChat(options?: UseChatOptions) {
28
337
  store.addMessage(assistantMsg)
29
338
  store.setStreaming(true)
30
339
 
31
- if (!options?.apiEndpoint) {
32
- // Mock response if no endpoint configured
33
- setTimeout(() => {
34
- store.updateLastAssistant(
35
- "I'm your AI assistant. This is a demo response — connect an API endpoint to enable real conversations."
36
- )
37
- store.setStreaming(false)
38
- }, 800)
39
- return
40
- }
41
-
42
340
  try {
43
- const response = await fetch(options.apiEndpoint, {
341
+ if (connection) {
342
+ await runFayzAgentTurn(content, connection)
343
+ return
344
+ }
345
+
346
+ const response = await fetch(options!.apiEndpoint!, {
44
347
  method: 'POST',
45
348
  headers: { 'Content-Type': 'application/json' },
46
349
  body: JSON.stringify({
47
350
  messages: [
48
- ...(options.systemPrompt
351
+ ...(options?.systemPrompt
49
352
  ? [{ role: 'system', content: options.systemPrompt }]
50
353
  : []),
51
354
  ...store.messages.map((m) => ({ role: m.role, content: m.content })),
@@ -56,7 +359,6 @@ export function useChat(options?: UseChatOptions) {
56
359
 
57
360
  if (!response.ok) {
58
361
  store.updateLastAssistant('Sorry, something went wrong. Please try again.')
59
- store.setStreaming(false)
60
362
  return
61
363
  }
62
364
 
@@ -69,16 +371,79 @@ export function useChat(options?: UseChatOptions) {
69
371
  store.setStreaming(false)
70
372
  }
71
373
  },
72
- [store, options?.apiEndpoint, options?.systemPrompt]
374
+ [
375
+ store,
376
+ isConfigured,
377
+ connection,
378
+ runFayzAgentTurn,
379
+ options?.apiEndpoint,
380
+ options?.systemPrompt,
381
+ t,
382
+ ],
73
383
  )
74
384
 
385
+ /** Refresh the signed-in user's thread list (history drawer). */
386
+ const loadConversations = useCallback(async () => {
387
+ if (!connection) return
388
+ try {
389
+ const rows = await getFayzAgentClient(connection).listConversations(user?.id)
390
+ useChatStore.getState().setConversations(rows)
391
+ } catch {
392
+ // History is an enhancement — a failed load never breaks the chat.
393
+ }
394
+ }, [connection, user?.id])
395
+
396
+ /** Resume a past thread: hydrate its transcript + continue under its id. */
397
+ const resumeConversation = useCallback(
398
+ async (conversationId: string) => {
399
+ if (!connection) return
400
+ try {
401
+ const detail = await getFayzAgentClient(connection).getConversation(conversationId, user?.id)
402
+ const messages: ChatMessage[] = detail.messages
403
+ .filter((m) => m.role === 'USER' || m.role === 'ASSISTANT' || m.role === 'user' || m.role === 'assistant')
404
+ .map((m) => ({
405
+ id: m.id,
406
+ role: m.role.toLowerCase() === 'user' ? ('user' as const) : ('assistant' as const),
407
+ content: m.content,
408
+ timestamp: m.createdAt,
409
+ }))
410
+ const s = useChatStore.getState()
411
+ s.setMessages(messages)
412
+ s.setConversationId(detail.id)
413
+ } catch {
414
+ // Thread gone (archived / another device cleaned it) — start fresh.
415
+ useChatStore.getState().reset()
416
+ }
417
+ },
418
+ [connection, user?.id],
419
+ )
420
+
421
+ /** Fresh thread: clear the transcript, next turn creates a new conversation. */
422
+ const startNewConversation = useCallback(() => {
423
+ store.reset()
424
+ }, [store])
425
+
75
426
  return {
76
427
  messages: store.messages,
77
428
  isOpen: store.isOpen,
78
429
  isStreaming: store.isStreaming,
430
+ /** True when a real backend is wired — the project's Fayz agent or a custom endpoint. */
431
+ isConfigured,
79
432
  sendMessage,
80
433
  toggleOpen: store.toggleOpen,
81
434
  setOpen: store.setOpen,
82
435
  reset: store.reset,
436
+ /** Write awaiting human confirmation (render the ConfirmActionCard; the
437
+ * composer should stay disabled while set). */
438
+ pendingAction: store.pendingAction,
439
+ /** Card decision: true executes/approves, false declines. */
440
+ resolvePendingAction: store.resolvePendingAction,
441
+ /** Tool names executing right now (live activity chips). */
442
+ activeTools: store.activeTools,
443
+ /** History drawer data + actions. */
444
+ conversations: store.conversations,
445
+ loadConversations,
446
+ resumeConversation,
447
+ startNewConversation,
83
448
  }
84
449
  }
@@ -0,0 +1,23 @@
1
+ import { useCallback } from 'react'
2
+ import type { SystemPermission } from '@fayz-ai/core'
3
+ import { usePermissionsStore } from '../../permissions'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // useSystemPermission — native replacement for the org-management (system)
7
+ // permission check that used to live in the legacy shell/hooks/usePermission.
8
+ // Mirrors the owner bypass in permissions/context.tsx: a null profile (owner /
9
+ // no RBAC loaded) and the `owner` role pass through, so the owner is never
10
+ // blocked. Grants-based feature checks stay with usePermission(Optional).
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export function useSystemPermission(): (perm: SystemPermission) => boolean {
14
+ const currentProfile = usePermissionsStore((s) => s.currentProfile)
15
+ return useCallback(
16
+ (perm: SystemPermission) => {
17
+ if (!currentProfile) return true
18
+ if (currentProfile.id === 'owner' || currentProfile.name?.toLowerCase() === 'owner') return true
19
+ return currentProfile.systemPermissions?.includes(perm) ?? false
20
+ },
21
+ [currentProfile],
22
+ )
23
+ }
@@ -0,0 +1,127 @@
1
+ import { useCallback, useMemo, useRef } from 'react'
2
+ import { useOrganizationStore, useOrgAdapterOptional } from '../../org'
3
+ import { usePluginPrefs } from './usePluginPrefs'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // useTenantPluginSettings — PER-TENANT plugin settings, persisted server-side
7
+ //
8
+ // Unlike usePluginPrefs (per-browser localStorage), this hook stores each
9
+ // plugin's settings under the current organization so they follow the tenant
10
+ // across devices/sessions:
11
+ //
12
+ // tenants.settings = { ..., plugins: { [pluginId]: { key: value, ... } } }
13
+ //
14
+ // Reads come from the native org store (`currentOrg.settings.plugins[pluginId]`)
15
+ // merged over `defaults`. Writes optimistically patch the store and then
16
+ // persist via `adapter.updateOrg(orgId, { settings })` (lightly debounced so a
17
+ // burst of toggles collapses into one network round-trip).
18
+ //
19
+ // When no org adapter is mounted (standalone / mock-less demo), it transparently
20
+ // falls back to `usePluginPrefs` (localStorage) with the SAME api, so the caller
21
+ // never has to branch.
22
+ //
23
+ // NOTE (follow-up): persisting + re-hydrating these values is the contract here.
24
+ // Actually *consuming* them to change plugin behaviour is a separate follow-up —
25
+ // today most of these keys are inert flags that only round-trip through storage.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export interface TenantPluginSettings<T extends Record<string, unknown>> {
29
+ /** Get a single setting value (falls back to the provided default). */
30
+ get<K extends keyof T>(key: K): T[K]
31
+ /** Set a single setting value — optimistic + debounced persistence. */
32
+ set<K extends keyof T>(key: K, value: T[K]): void
33
+ /** Get all settings merged over defaults. */
34
+ getAll(): T
35
+ /** Whether values are persisted per-tenant (true) or per-browser fallback (false). */
36
+ isTenantScoped: boolean
37
+ }
38
+
39
+ /** Extract this plugin's settings blob from an org's settings, merged over defaults. */
40
+ function readForPlugin<T extends Record<string, unknown>>(
41
+ settings: unknown,
42
+ pluginId: string,
43
+ defaults: T,
44
+ ): T {
45
+ const root = settings && typeof settings === 'object' ? (settings as Record<string, unknown>) : {}
46
+ const plugins = root.plugins && typeof root.plugins === 'object' ? (root.plugins as Record<string, unknown>) : {}
47
+ const forPlugin = plugins[pluginId]
48
+ const merged = forPlugin && typeof forPlugin === 'object' ? (forPlugin as Partial<T>) : {}
49
+ return { ...defaults, ...merged }
50
+ }
51
+
52
+ const DEBOUNCE_MS = 400
53
+
54
+ export function useTenantPluginSettings<T extends Record<string, unknown>>(
55
+ pluginId: string,
56
+ defaults: T,
57
+ ): TenantPluginSettings<T> {
58
+ const currentOrg = useOrganizationStore((s) => s.currentOrg)
59
+ const setCurrentOrg = useOrganizationStore((s) => s.setCurrentOrg)
60
+ const adapter = useOrgAdapterOptional()
61
+
62
+ // Always mounted (hooks can't be conditional). Used as the fallback store when
63
+ // there's no org adapter to persist against.
64
+ const localPrefs = usePluginPrefs<T>(pluginId, defaults)
65
+
66
+ const isTenantScoped = !!(adapter && currentOrg)
67
+
68
+ const stored = useMemo(
69
+ () => readForPlugin(currentOrg?.settings, pluginId, defaults),
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ [currentOrg?.settings, pluginId],
72
+ )
73
+
74
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
75
+
76
+ const scheduleWrite = useCallback(
77
+ (orgId: string, settings: Record<string, unknown>) => {
78
+ if (!adapter) return
79
+ if (timerRef.current) clearTimeout(timerRef.current)
80
+ timerRef.current = setTimeout(() => {
81
+ adapter
82
+ .updateOrg(orgId, { settings })
83
+ .then((updated) => setCurrentOrg(updated))
84
+ .catch(() => {
85
+ /* keep the optimistic value; a later save or refresh reconciles */
86
+ })
87
+ }, DEBOUNCE_MS)
88
+ },
89
+ [adapter, setCurrentOrg],
90
+ )
91
+
92
+ const set = useCallback(
93
+ <K extends keyof T>(key: K, value: T[K]): void => {
94
+ const org = useOrganizationStore.getState().currentOrg
95
+ if (!adapter || !org) {
96
+ localPrefs.set(key, value)
97
+ return
98
+ }
99
+ // Read the freshest org from the store so rapid successive sets accumulate
100
+ // instead of clobbering each other via a stale closure.
101
+ const settings = (org.settings ?? {}) as Record<string, unknown>
102
+ const plugins = (settings.plugins ?? {}) as Record<string, unknown>
103
+ const nextForPlugin = { ...readForPlugin(settings, pluginId, defaults), [key]: value }
104
+ const nextSettings = { ...settings, plugins: { ...plugins, [pluginId]: nextForPlugin } }
105
+ // Optimistic: reflect immediately so the toggle updates without a round-trip.
106
+ setCurrentOrg({ ...org, settings: nextSettings })
107
+ scheduleWrite(org.id, nextSettings)
108
+ },
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ [adapter, pluginId, setCurrentOrg, scheduleWrite, localPrefs],
111
+ )
112
+
113
+ const get = useCallback(
114
+ <K extends keyof T>(key: K): T[K] => (isTenantScoped ? stored[key] : localPrefs.get(key)),
115
+ [isTenantScoped, stored, localPrefs],
116
+ )
117
+
118
+ const getAll = useCallback(
119
+ (): T => (isTenantScoped ? stored : localPrefs.getAll()),
120
+ [isTenantScoped, stored, localPrefs],
121
+ )
122
+
123
+ return useMemo(
124
+ () => ({ get, set, getAll, isTenantScoped }),
125
+ [get, set, getAll, isTenantScoped],
126
+ )
127
+ }