@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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|
+
}
|