@unitninety/site-kit 0.0.0-stage → 1.6.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/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Shared website features in Next
|
|
2
|
+
|
|
3
|
+
The `@unitninety/site-kit/features` entry point targets Next 16.3.8–16.x and React 19.
|
|
4
|
+
It uses Next's committed pathname updates; wrap `SiteFeatures` around the root layout's
|
|
5
|
+
children from the first render, mount the server `ConsentDefaults` once before it, and import `@unitninety/site-kit/features/styles.css`. The internal pathname
|
|
6
|
+
reporter has its own Suspense boundary. Pure `features/contracts` has no Next dependency.
|
|
7
|
+
No non-Next runtime support is claimed. The package's Next peer is optional for consumers
|
|
8
|
+
using only contracts or server APIs, but required for the feature client entry point.
|
|
9
|
+
|
|
10
|
+
With the same-origin gateway installed, enabling Feedback in U90 renders the complete
|
|
11
|
+
stock widget. No form declaration or site-specific payload mapping is required. Override
|
|
12
|
+
`components.Feedback` with a stable React component to supply complete custom markup.
|
|
13
|
+
Both presentations consume normalized `config.feedback` options (fallback:
|
|
14
|
+
`FeedbackWidgetSettingsSchema.parse({})`), including labels and question visibility.
|
|
15
|
+
The default comment label is **Tell us more**.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { FeatureDialog, useFeedback } from '@unitninety/site-kit/features'
|
|
19
|
+
function MyFeedback() {
|
|
20
|
+
const { launcherRef, ...feedback } = useFeedback()
|
|
21
|
+
// Local answer fields belong inside this registered component.
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<button ref={launcherRef} onClick={feedback.show}>
|
|
25
|
+
Feedback
|
|
26
|
+
</button>
|
|
27
|
+
<FeatureDialog name="feedback" label="Feedback">
|
|
28
|
+
<p>Feedback for {feedback.page?.path}</p>
|
|
29
|
+
{/* Render composition when idle; explicit retry/new actions on error. */}
|
|
30
|
+
</FeatureDialog>
|
|
31
|
+
</>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`useFeedback(): FeedbackController` returns `open`, `status` (`idle | submitting |
|
|
37
|
+
success | error`), safe `error`, `page` and `currentPage` (`Readonly<PageContext> | null`),
|
|
38
|
+
`show()`, `hide()`, `submit({ rating?: 1|2|3|4|5, comment?: string }): Promise<void>`,
|
|
39
|
+
`retry(): Promise<void>`, `startNew(): void`, and `launcherRef: RefCallback<HTMLButtonElement>`.
|
|
40
|
+
Attach the ref to the presentation's single actual launcher. It restores focus to the
|
|
41
|
+
current launcher if navigation replaced the original one. Switching panels preserves
|
|
42
|
+
the original overlay's focus identity.
|
|
43
|
+
|
|
44
|
+
The root owns submission state; every hook consumer joins the same pending attempt.
|
|
45
|
+
Each valid intentional send receives one request ID and captures normalized answers,
|
|
46
|
+
pathname and an optional bounded title. Invalid/blank answers require no session or proof.
|
|
47
|
+
An uncertain result retains that exact attempt. `retry()` resends the same ID/page/answers;
|
|
48
|
+
`submit()` in that state is also a retry and **ignores changed arguments**. Never label a
|
|
49
|
+
new action “Retry”. `startNew()` explicitly abandons the old attempt and clears local draft;
|
|
50
|
+
it is inert while a request is pending. Successful sends remain inert until startNew or
|
|
51
|
+
pathname navigation. A browser abort cannot roll back a write that reached the server.
|
|
52
|
+
|
|
53
|
+
Committed pathname changes remount **only** the registered Feedback presentation, clearing
|
|
54
|
+
ordinary React draft state. Root children, Ask AI, coalesced session/proof and pending or
|
|
55
|
+
uncertain Feedback snapshots survive. Show the captured `page` while pending/retrying;
|
|
56
|
+
`currentPage` may differ after navigation. Query/hash/title-only changes do not clear drafts.
|
|
57
|
+
Do not keep draft state in module singletons, sessionStorage or above the registered
|
|
58
|
+
component: the boundary cannot reset external state. Hiding a panel does not cancel sending.
|
|
59
|
+
Disabling Feedback clears unsent draft and cancels only its operation; re-enabling never
|
|
60
|
+
automatically sends. Endpoint replacement/unmount discards that authority's state.
|
|
61
|
+
|
|
62
|
+
Feedback uses the configured runtime endpoint and canonical `FeedbackInput`. Each new logical
|
|
63
|
+
submission obtains a signed action grant, then sends the body with that grant. Public
|
|
64
|
+
verification configuration is refreshed before proof acquisition. Saved Disabled mode returns
|
|
65
|
+
`null` and skips provider scripts, widgets and injected browser adapters; the platform still
|
|
66
|
+
checks its saved mode and issues the same signed authority. Default and custom providers fail
|
|
67
|
+
closed on missing setup or failed checks. Proof, grants, tenant details and credentials never
|
|
68
|
+
belong in answer fields.
|
|
69
|
+
|
|
70
|
+
Interactive proof acquisition has a 120-second deadline; each HTTP exchange is bounded to ten
|
|
71
|
+
seconds. Cancellation cannot cancel another feature's shared session wait. Known response
|
|
72
|
+
fields are validated and response bodies are bounded before success. Hidden questions are
|
|
73
|
+
omitted at first capture; later settings changes cannot alter a retry.
|
|
74
|
+
|
|
75
|
+
The scoped theme variables are `--u90-feature-background`, `--u90-feature-foreground`,
|
|
76
|
+
`--u90-feature-accent`, `--u90-feature-radius`, and `--u90-feature-shadow`.
|
|
77
|
+
|
|
78
|
+
See [consent setup](../consent/README.md) for the server root bootstrap, optional custom registration, remote authority, lifecycle limits and preferences hook. Consent does not reset the Feedback presentation, session or focus lifetime.
|
|
79
|
+
|
|
80
|
+
## Ask AI stock setup and remix
|
|
81
|
+
|
|
82
|
+
Use the same root `SiteFeatures`, same-origin gateway, verification adapter and stylesheet
|
|
83
|
+
setup above. Ask AI starts disabled. In U90, a manager connects a tenant-owned provider
|
|
84
|
+
key, explicitly chooses an available model and allowances, selects eligible published
|
|
85
|
+
knowledge (published FAQs can stand alone), and publishes the generation configuration.
|
|
86
|
+
Enable Ask AI in feature settings to show the complete stock widget. Provider keys stay
|
|
87
|
+
server-only; the browser never supplies a key, model or private prompt. See the
|
|
88
|
+
[management contract](../../../../../docs/superpowers/specs/2026-10-05-ask-ai-management.md)
|
|
89
|
+
for permissions, configuration and activation boundaries.
|
|
90
|
+
|
|
91
|
+
For custom markup, register a stable component through `components.AskAI` in your existing
|
|
92
|
+
client root wrapper. This example keeps the shared accessible conversation content:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
'use client'
|
|
96
|
+
import type { SiteFeaturesProps } from '@unitninety/site-kit/features'
|
|
97
|
+
import { AskAiContent, FeatureDialog, SiteFeatures, useAskAI } from '@unitninety/site-kit/features'
|
|
98
|
+
|
|
99
|
+
function MyAskAI() {
|
|
100
|
+
const ai = useAskAI()
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
<button ref={ai.launcherRef} onClick={ai.show}>
|
|
104
|
+
{ai.settings.launcherLabel}
|
|
105
|
+
</button>
|
|
106
|
+
<FeatureDialog name="askAi" label={ai.settings.title}>
|
|
107
|
+
<AskAiContent controller={ai} />
|
|
108
|
+
</FeatureDialog>
|
|
109
|
+
</>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function Features(props: SiteFeaturesProps) {
|
|
114
|
+
return <SiteFeatures {...props} components={{ ...props.components, AskAI: MyAskAI }} />
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Keep this root mounted across page navigation: it owns conversation, draft, session and
|
|
119
|
+
pending-request state even when the presentation closes or remounts. Endpoint replacement
|
|
120
|
+
or root unmount discards that authority's state. `useAskAI()` supports a complete remix;
|
|
121
|
+
`AskAiContent` is optional. Honor remote copy/settings and deliberately document any brand
|
|
122
|
+
colours or corners that your remix overrides; the stock widget honors saved appearance.
|
|
123
|
+
|
|
124
|
+
For an uncertain send, `retry()` reuses the canonical request ID and captured body.
|
|
125
|
+
`retryAnswer()` after a confirmed terminal failure is an explicit **new attempt** with a
|
|
126
|
+
new ID and may incur a new charge; never label it as a same-request retry. Recovery uses
|
|
127
|
+
GET and never generates an answer. Session storage persists only a known conversation ID,
|
|
128
|
+
scoped by endpoint: no transcript, draft, request IDs, tokens or cursors. Refresh before the
|
|
129
|
+
first receipt can lose recovery correlation. See the
|
|
130
|
+
[widget contract](../../../../../docs/superpowers/specs/2026-10-05-ask-ai-widgets.md)
|
|
131
|
+
for full hook, recovery, stop, focus and lifecycle behavior.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import {
|
|
3
|
+
lazy,
|
|
4
|
+
Suspense,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ComponentType,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import type { FeedbackInput, PublicFeatureBase } from '../contracts'
|
|
15
|
+
import { FeatureContext, type OverlayName } from './context'
|
|
16
|
+
import { AskAiProvider } from './AskAiProvider'
|
|
17
|
+
import { FeedbackProvider } from './FeedbackProvider'
|
|
18
|
+
import { ConsentRuntime } from './ConsentRuntime'
|
|
19
|
+
import type { CustomConsentRegistration } from '../consent/custom'
|
|
20
|
+
import { acquireVerificationProof } from './verification'
|
|
21
|
+
import type { VerificationAction } from '../contracts'
|
|
22
|
+
import { createFeatureTransport, waitForSignal, type VisitorVerification } from './transport'
|
|
23
|
+
|
|
24
|
+
const builtinLoaders: Partial<Record<OverlayName, () => Promise<{ default: ComponentType }>>> = {
|
|
25
|
+
feedback: () => import('./FeedbackWidget'),
|
|
26
|
+
askAi: () => import('./AskAiWidget'),
|
|
27
|
+
}
|
|
28
|
+
const builtins = Object.fromEntries(
|
|
29
|
+
Object.entries(builtinLoaders).map(([key, load]) => [key, lazy(load)]),
|
|
30
|
+
) as Partial<Record<OverlayName, ComponentType>>
|
|
31
|
+
export type SiteFeaturesProps = {
|
|
32
|
+
endpoint?: string
|
|
33
|
+
components?: { AskAI?: ComponentType; Feedback?: ComponentType }
|
|
34
|
+
verification?: VisitorVerification
|
|
35
|
+
customConsent?: CustomConsentRegistration
|
|
36
|
+
children?: ReactNode
|
|
37
|
+
}
|
|
38
|
+
export function SiteFeatures(props: SiteFeaturesProps) {
|
|
39
|
+
// A changed endpoint is a different authority and must not retain its configuration/panel.
|
|
40
|
+
return <Runtime key={props.endpoint ?? '/api/u90/features/v1'} {...props} />
|
|
41
|
+
}
|
|
42
|
+
function Runtime({
|
|
43
|
+
endpoint,
|
|
44
|
+
components,
|
|
45
|
+
verification,
|
|
46
|
+
customConsent,
|
|
47
|
+
children,
|
|
48
|
+
}: SiteFeaturesProps) {
|
|
49
|
+
const adapter = useRef(verification)
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
adapter.current = verification
|
|
52
|
+
}, [verification])
|
|
53
|
+
const transport = useRef<ReturnType<typeof createFeatureTransport> | null>(null)
|
|
54
|
+
const acquireProof = useCallback(
|
|
55
|
+
async (options: { signal: AbortSignal; action?: VerificationAction }) => {
|
|
56
|
+
if (adapter.current) return adapter.current.acquireProof(options)
|
|
57
|
+
// Refresh public connection configuration at the action boundary, so a saved
|
|
58
|
+
// provider replacement uses its new key even before the background refresh.
|
|
59
|
+
const config = await transport.current!.config(options.signal)
|
|
60
|
+
if (!config.verification) throw new Error('Verification unavailable')
|
|
61
|
+
return acquireVerificationProof(
|
|
62
|
+
config.verification,
|
|
63
|
+
options.action ?? 'ask_ai_session',
|
|
64
|
+
options,
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
[],
|
|
68
|
+
)
|
|
69
|
+
// Create the transport only from an effect or event. The root survives adapter
|
|
70
|
+
// updates, so an in-flight logical session attempt remains coalesced.
|
|
71
|
+
const getTransport = useCallback(() => {
|
|
72
|
+
if (!transport.current)
|
|
73
|
+
transport.current = createFeatureTransport({ endpoint, verification: { acquireProof } })
|
|
74
|
+
return transport.current
|
|
75
|
+
}, [endpoint, acquireProof])
|
|
76
|
+
const [config, setConfig] = useState<PublicFeatureBase | null>(null)
|
|
77
|
+
const [error, setError] = useState<string | null>(null)
|
|
78
|
+
const [active, setActive] = useState<OverlayName | null>(null)
|
|
79
|
+
const [announcement, announce] = useState('')
|
|
80
|
+
const activeRef = useRef(active)
|
|
81
|
+
useLayoutEffect(() => {
|
|
82
|
+
activeRef.current = active
|
|
83
|
+
}, [active])
|
|
84
|
+
const lifetime = useRef<AbortController | null>(null)
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
const controller = new AbortController()
|
|
87
|
+
lifetime.current = controller
|
|
88
|
+
return () => controller.abort()
|
|
89
|
+
}, [])
|
|
90
|
+
const committedConfig = useRef(config)
|
|
91
|
+
useLayoutEffect(() => {
|
|
92
|
+
committedConfig.current = config
|
|
93
|
+
}, [config])
|
|
94
|
+
const launchers = useRef(new Map<OverlayName, { owner: symbol; node: HTMLButtonElement }>())
|
|
95
|
+
const registerLauncher = useCallback(
|
|
96
|
+
(name: OverlayName, owner: symbol, node: HTMLButtonElement | null) => {
|
|
97
|
+
if (node) launchers.current.set(name, { owner, node })
|
|
98
|
+
else if (launchers.current.get(name)?.owner === owner) launchers.current.delete(name)
|
|
99
|
+
},
|
|
100
|
+
[],
|
|
101
|
+
)
|
|
102
|
+
const returnName = useRef<OverlayName | null>(null)
|
|
103
|
+
const returnFocus = useRef<HTMLElement | null>(null)
|
|
104
|
+
const abort = useRef<AbortController | null>(null)
|
|
105
|
+
const pending = useRef<Promise<void> | null>(null)
|
|
106
|
+
const lastRefresh = useRef(0)
|
|
107
|
+
const close = useCallback(() => {
|
|
108
|
+
setActive(null)
|
|
109
|
+
const target = returnFocus.current
|
|
110
|
+
const origin = returnName.current
|
|
111
|
+
returnFocus.current = null
|
|
112
|
+
returnName.current = null
|
|
113
|
+
// Effects release the dialog's focus containment before restoring the launcher.
|
|
114
|
+
queueMicrotask(() => {
|
|
115
|
+
const usable = (node?: HTMLElement | null) =>
|
|
116
|
+
node?.isConnected &&
|
|
117
|
+
!node.matches(':disabled') &&
|
|
118
|
+
!node.closest('[hidden], [inert], [aria-hidden="true"]') &&
|
|
119
|
+
node.getClientRects().length > 0 &&
|
|
120
|
+
getComputedStyle(node).visibility === 'visible'
|
|
121
|
+
if (usable(target)) target!.focus()
|
|
122
|
+
else {
|
|
123
|
+
const replacement = origin && launchers.current.get(origin)?.node
|
|
124
|
+
if (replacement && usable(replacement)) replacement.focus()
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}, [])
|
|
128
|
+
const refresh = useCallback((): Promise<void> => {
|
|
129
|
+
if (pending.current) return pending.current
|
|
130
|
+
const controller = new AbortController()
|
|
131
|
+
abort.current = controller
|
|
132
|
+
lastRefresh.current = Date.now()
|
|
133
|
+
pending.current = getTransport()
|
|
134
|
+
.config(controller.signal)
|
|
135
|
+
.then((next) => {
|
|
136
|
+
if (controller.signal.aborted) return
|
|
137
|
+
setConfig((current) => (current && current.revision > next.revision ? current : next))
|
|
138
|
+
setError(null)
|
|
139
|
+
if (activeRef.current && !next.enabled[activeRef.current]) close()
|
|
140
|
+
})
|
|
141
|
+
.catch(() => {
|
|
142
|
+
if (!controller.signal.aborted) setError('Site features unavailable. Please retry.')
|
|
143
|
+
})
|
|
144
|
+
.finally(() => {
|
|
145
|
+
if (abort.current === controller) pending.current = null
|
|
146
|
+
})
|
|
147
|
+
return pending.current
|
|
148
|
+
}, [getTransport, close])
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
void refresh()
|
|
151
|
+
const visibleRefresh = () => {
|
|
152
|
+
if (document.visibilityState === 'visible' && Date.now() - lastRefresh.current >= 60000)
|
|
153
|
+
void refresh()
|
|
154
|
+
}
|
|
155
|
+
const timer = setInterval(visibleRefresh, 60000)
|
|
156
|
+
document.addEventListener('visibilitychange', visibleRefresh)
|
|
157
|
+
return () => {
|
|
158
|
+
clearInterval(timer)
|
|
159
|
+
document.removeEventListener('visibilitychange', visibleRefresh)
|
|
160
|
+
abort.current?.abort()
|
|
161
|
+
pending.current = null
|
|
162
|
+
}
|
|
163
|
+
}, [refresh])
|
|
164
|
+
const open = useCallback(
|
|
165
|
+
(name: OverlayName) => {
|
|
166
|
+
if (!config?.enabled[name]) return
|
|
167
|
+
if (activeRef.current === null) {
|
|
168
|
+
returnFocus.current =
|
|
169
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
170
|
+
returnName.current = name
|
|
171
|
+
}
|
|
172
|
+
setActive(name)
|
|
173
|
+
void refresh()
|
|
174
|
+
},
|
|
175
|
+
[config, refresh],
|
|
176
|
+
)
|
|
177
|
+
const overlay = useMemo(() => ({ active, open, close }), [active, open, close])
|
|
178
|
+
const ensureSession = useCallback(
|
|
179
|
+
async (signal?: AbortSignal) => {
|
|
180
|
+
try {
|
|
181
|
+
const rootSignal = lifetime.current!.signal
|
|
182
|
+
const work = getTransport().ensureSession(rootSignal)
|
|
183
|
+
await (signal ? waitForSignal(work, signal) : work)
|
|
184
|
+
if (!rootSignal.aborted && !signal?.aborted) setError(null)
|
|
185
|
+
} catch (cause) {
|
|
186
|
+
if (!lifetime.current?.signal.aborted && !signal?.aborted)
|
|
187
|
+
setError('Verification or session unavailable. Please retry.')
|
|
188
|
+
throw cause
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
[getTransport],
|
|
192
|
+
)
|
|
193
|
+
const submitFeedback = useCallback(
|
|
194
|
+
async (input: FeedbackInput, signal: AbortSignal) => {
|
|
195
|
+
const rootSignal = lifetime.current!.signal
|
|
196
|
+
const operationSignal = AbortSignal.any([signal, rootSignal])
|
|
197
|
+
operationSignal.throwIfAborted()
|
|
198
|
+
operationSignal.throwIfAborted()
|
|
199
|
+
if (!committedConfig.current?.enabled.feedback) throw new Error('Feedback disabled')
|
|
200
|
+
return getTransport().feedback(input, operationSignal)
|
|
201
|
+
},
|
|
202
|
+
[getTransport],
|
|
203
|
+
)
|
|
204
|
+
const search = useCallback(
|
|
205
|
+
(query: string, signal: AbortSignal) => getTransport().search(query, signal),
|
|
206
|
+
[getTransport],
|
|
207
|
+
)
|
|
208
|
+
const value = useMemo(
|
|
209
|
+
() => ({
|
|
210
|
+
config,
|
|
211
|
+
error,
|
|
212
|
+
refresh,
|
|
213
|
+
ensureSession,
|
|
214
|
+
overlay,
|
|
215
|
+
announce,
|
|
216
|
+
submitFeedback,
|
|
217
|
+
registerLauncher,
|
|
218
|
+
search,
|
|
219
|
+
}),
|
|
220
|
+
[config, error, refresh, ensureSession, overlay, submitFeedback, registerLauncher, search],
|
|
221
|
+
)
|
|
222
|
+
const AskAI = components?.AskAI ?? builtins.askAi
|
|
223
|
+
const Feedback = components?.Feedback ?? builtins.feedback
|
|
224
|
+
return (
|
|
225
|
+
<FeatureContext.Provider value={value}>
|
|
226
|
+
<ConsentRuntime
|
|
227
|
+
endpoint={endpoint ?? '/api/u90/features/v1'}
|
|
228
|
+
config={config}
|
|
229
|
+
customConsent={customConsent}
|
|
230
|
+
>
|
|
231
|
+
<AskAiProvider endpoint={endpoint ?? '/api/u90/features/v1'} getTransport={getTransport}>
|
|
232
|
+
<FeedbackProvider presentation={Feedback}>
|
|
233
|
+
{children}
|
|
234
|
+
<div className="u90-features">
|
|
235
|
+
<Suspense fallback={null}>{config?.enabled.askAi && AskAI && <AskAI />}</Suspense>
|
|
236
|
+
<div
|
|
237
|
+
className="u90-feature-announcement"
|
|
238
|
+
role="status"
|
|
239
|
+
aria-live="polite"
|
|
240
|
+
aria-atomic="true"
|
|
241
|
+
>
|
|
242
|
+
{error ?? announcement}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</FeedbackProvider>
|
|
246
|
+
</AskAiProvider>
|
|
247
|
+
</ConsentRuntime>
|
|
248
|
+
</FeatureContext.Provider>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AiConversationPageSchema,
|
|
3
|
+
type AiMessagePart,
|
|
4
|
+
type AiSource,
|
|
5
|
+
type AiTurnState,
|
|
6
|
+
} from '../contracts'
|
|
7
|
+
export type AskAiMessage = {
|
|
8
|
+
id: string
|
|
9
|
+
requestId: string
|
|
10
|
+
role: 'user' | 'assistant'
|
|
11
|
+
text: string
|
|
12
|
+
sources: AiSource[]
|
|
13
|
+
state: AiTurnState
|
|
14
|
+
sequence: number
|
|
15
|
+
newAttempt?: boolean
|
|
16
|
+
}
|
|
17
|
+
export type RecoveryState = {
|
|
18
|
+
conversationId: string
|
|
19
|
+
historyEpoch: number | null
|
|
20
|
+
messages: AskAiMessage[]
|
|
21
|
+
fragments: Map<string, AiMessagePart>
|
|
22
|
+
cursor: string | null
|
|
23
|
+
snapshot: number | null
|
|
24
|
+
}
|
|
25
|
+
export const emptyRecovery = (conversationId: string): RecoveryState => ({
|
|
26
|
+
conversationId,
|
|
27
|
+
historyEpoch: null,
|
|
28
|
+
messages: [],
|
|
29
|
+
fragments: new Map(),
|
|
30
|
+
cursor: null,
|
|
31
|
+
snapshot: null,
|
|
32
|
+
})
|
|
33
|
+
/** Fragments never modify displayed text until their whole immutable revision is available. */
|
|
34
|
+
export function mergeRecoveryPage(current: RecoveryState, input: unknown): RecoveryState {
|
|
35
|
+
const page = AiConversationPageSchema.parse(input)
|
|
36
|
+
if (page.conversationId !== current.conversationId) throw Error('Changed conversation')
|
|
37
|
+
const reset = current.historyEpoch !== null && current.historyEpoch !== page.historyEpoch
|
|
38
|
+
if (
|
|
39
|
+
!reset &&
|
|
40
|
+
current.cursor &&
|
|
41
|
+
current.snapshot !== null &&
|
|
42
|
+
current.snapshot !== page.snapshotUpperSequence
|
|
43
|
+
)
|
|
44
|
+
throw Error('Changed snapshot')
|
|
45
|
+
const state: RecoveryState = {
|
|
46
|
+
...current,
|
|
47
|
+
historyEpoch: page.historyEpoch,
|
|
48
|
+
snapshot: page.snapshotUpperSequence,
|
|
49
|
+
cursor: page.nextCursor,
|
|
50
|
+
messages: reset ? [] : [...current.messages],
|
|
51
|
+
fragments: reset ? new Map() : new Map(current.fragments),
|
|
52
|
+
}
|
|
53
|
+
for (const part of page.parts) {
|
|
54
|
+
const prior = state.fragments.get(part.messageId)
|
|
55
|
+
let assembled = part
|
|
56
|
+
if (part.textStart !== 0) {
|
|
57
|
+
if (
|
|
58
|
+
!prior ||
|
|
59
|
+
prior.textEnd !== part.textStart ||
|
|
60
|
+
prior.contentRevision !== part.contentRevision ||
|
|
61
|
+
prior.requestId !== part.requestId ||
|
|
62
|
+
prior.sequence !== part.sequence ||
|
|
63
|
+
prior.role !== part.role ||
|
|
64
|
+
prior.state !== part.state ||
|
|
65
|
+
prior.totalLength !== part.totalLength ||
|
|
66
|
+
JSON.stringify(prior.sources) !== JSON.stringify(part.sources)
|
|
67
|
+
)
|
|
68
|
+
throw Error('Changed fragment')
|
|
69
|
+
assembled = { ...part, text: prior.text + part.text, textStart: 0 }
|
|
70
|
+
}
|
|
71
|
+
if (!assembled.lastPart) {
|
|
72
|
+
state.fragments.set(part.messageId, assembled)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
state.fragments.delete(part.messageId)
|
|
76
|
+
// A recovered local request replaces both placeholder identity and any previously streamed text.
|
|
77
|
+
state.messages = state.messages.filter(
|
|
78
|
+
(m) => m.id !== part.messageId && !(m.requestId === part.requestId && m.role === part.role),
|
|
79
|
+
)
|
|
80
|
+
state.messages.push({
|
|
81
|
+
id: part.messageId,
|
|
82
|
+
requestId: part.requestId,
|
|
83
|
+
sequence: part.sequence,
|
|
84
|
+
role: part.role,
|
|
85
|
+
text: assembled.text,
|
|
86
|
+
sources: part.sources,
|
|
87
|
+
state: part.state,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
state.messages.sort((a, b) => a.sequence - b.sequence)
|
|
91
|
+
return state
|
|
92
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { AiStreamEventSchema, type AiStreamEvent } from '../contracts'
|
|
2
|
+
import { FeatureClientError, waitForSignal } from './transport'
|
|
3
|
+
/** One HTTP response is one sequence lifetime. Terminal replay starts at zero again. */
|
|
4
|
+
export async function consumeAiStream(
|
|
5
|
+
response: Response,
|
|
6
|
+
requestId: string,
|
|
7
|
+
receive: (event: AiStreamEvent) => void,
|
|
8
|
+
signal: AbortSignal,
|
|
9
|
+
) {
|
|
10
|
+
if (!response.headers.get('content-type')?.startsWith('text/event-stream'))
|
|
11
|
+
throw new FeatureClientError()
|
|
12
|
+
const reader = response.body?.getReader()
|
|
13
|
+
if (!reader) throw new FeatureClientError()
|
|
14
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
15
|
+
let buffer = '',
|
|
16
|
+
sequence = -1,
|
|
17
|
+
terminal = false,
|
|
18
|
+
total = 0
|
|
19
|
+
const invalid = () => new FeatureClientError('unavailable')
|
|
20
|
+
try {
|
|
21
|
+
for (;;) {
|
|
22
|
+
const { done, value } = await waitForSignal(reader.read(), signal)
|
|
23
|
+
if (done) break
|
|
24
|
+
total += value.byteLength
|
|
25
|
+
if (total > 2 * 1024 * 1024) throw invalid()
|
|
26
|
+
buffer += decoder.decode(value, { stream: true })
|
|
27
|
+
for (;;) {
|
|
28
|
+
const match = /\r?\n\r?\n/.exec(buffer)
|
|
29
|
+
if (!match) break
|
|
30
|
+
const frame = buffer.slice(0, match.index)
|
|
31
|
+
buffer = buffer.slice(match.index + match[0].length)
|
|
32
|
+
if (!frame || frame.startsWith(':')) continue
|
|
33
|
+
if (new TextEncoder().encode(frame).length > 131072) throw invalid()
|
|
34
|
+
if (!frame.startsWith('data: ') || frame.includes('\n')) throw invalid()
|
|
35
|
+
const parsed = AiStreamEventSchema.safeParse(JSON.parse(frame.slice(6)))
|
|
36
|
+
if (!parsed.success || parsed.data.requestId.toLowerCase() !== requestId.toLowerCase())
|
|
37
|
+
throw invalid()
|
|
38
|
+
const event = parsed.data
|
|
39
|
+
if (event.sequence <= sequence) continue
|
|
40
|
+
if (
|
|
41
|
+
terminal ||
|
|
42
|
+
event.sequence !== sequence + 1 ||
|
|
43
|
+
(sequence === -1 && event.type !== 'start') ||
|
|
44
|
+
(sequence >= 0 && event.type === 'start')
|
|
45
|
+
)
|
|
46
|
+
throw invalid()
|
|
47
|
+
sequence = event.sequence
|
|
48
|
+
terminal = event.type === 'done' || event.type === 'error'
|
|
49
|
+
receive(event)
|
|
50
|
+
}
|
|
51
|
+
if (new TextEncoder().encode(buffer).length > 131072) throw invalid()
|
|
52
|
+
}
|
|
53
|
+
buffer += decoder.decode()
|
|
54
|
+
if (buffer.trim() || !terminal) throw invalid()
|
|
55
|
+
} finally {
|
|
56
|
+
await reader.cancel().catch(() => {})
|
|
57
|
+
reader.releaseLock()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { createContext, useContext } from 'react'
|
|
3
|
+
import type { SearchHit, FeedbackInput, FeedbackReceipt, PublicFeatureBase } from '../contracts'
|
|
4
|
+
export type OverlayName = 'askAi' | 'feedback'
|
|
5
|
+
export type FeatureRuntime = {
|
|
6
|
+
config: PublicFeatureBase | null
|
|
7
|
+
error: string | null
|
|
8
|
+
refresh: () => Promise<void>
|
|
9
|
+
ensureSession: (signal?: AbortSignal) => Promise<void>
|
|
10
|
+
overlay: { active: OverlayName | null; open: (name: OverlayName) => void; close: () => void }
|
|
11
|
+
search: (query: string, signal: AbortSignal) => Promise<SearchHit[]>
|
|
12
|
+
submitFeedback: (input: FeedbackInput, signal: AbortSignal) => Promise<FeedbackReceipt>
|
|
13
|
+
registerLauncher: (name: OverlayName, owner: symbol, node: HTMLButtonElement | null) => void
|
|
14
|
+
announce: (message: string) => void
|
|
15
|
+
}
|
|
16
|
+
export const FeatureContext = createContext<FeatureRuntime | null>(null)
|
|
17
|
+
export function useSiteFeatures(): FeatureRuntime {
|
|
18
|
+
const context = useContext(FeatureContext)
|
|
19
|
+
if (!context) throw new Error('Feature hooks require a SiteFeatures root.')
|
|
20
|
+
return context
|
|
21
|
+
}
|
|
22
|
+
export function useFeatureOverlay() {
|
|
23
|
+
return useSiteFeatures().overlay
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
export { acquireVerificationProof, useFormVerification, VERIFICATION_CSP } from './verification'
|
|
3
|
+
export { SiteFeatures, type SiteFeaturesProps } from './SiteFeatures'
|
|
4
|
+
export {
|
|
5
|
+
useSiteFeatures,
|
|
6
|
+
useFeatureOverlay,
|
|
7
|
+
type FeatureRuntime,
|
|
8
|
+
type OverlayName,
|
|
9
|
+
} from './context'
|
|
10
|
+
export { FeatureDialog } from './overlay'
|
|
11
|
+
export { FeatureClientError, type VisitorVerification } from './transport'
|
|
12
|
+
|
|
13
|
+
export { useFeedback, type FeedbackController, type FeedbackAnswer } from './useFeedback'
|
|
14
|
+
|
|
15
|
+
export { cookieScriptAdapter, normalizeCookieScript } from '../consent/cookieScript'
|
|
16
|
+
export { customConsentAdapter, type CustomConsentRegistration } from '../consent/custom'
|
|
17
|
+
|
|
18
|
+
export { useConsent, type ConsentController } from './useConsent'
|
|
19
|
+
export { createConsentLoader } from '../consent/loader'
|
|
20
|
+
|
|
21
|
+
export { useSiteSearch } from './useSiteSearch'
|
|
22
|
+
|
|
23
|
+
export { useAskAI, type AskAiController } from './useAskAI'
|
|
24
|
+
export { AskAiContent } from './AskAiWidget'
|