feat(client): add conversation node engine and contextual slots

This commit is contained in:
imccyu
2026-08-09 15:48:19 +08:00
parent aa623b6e7a
commit 81d6886006
20 changed files with 2475 additions and 900 deletions

View File

@@ -101,15 +101,40 @@ export interface SlotEntryDef {
kind: SlotKind
scope: SlotScope
owner?: object
/**
* Optional keyed-entry prop table. A keyed registration contributes one
* literal key and receives the corresponding prop share; ordinary owner
* props remain common to every key.
*/
keyProps?: Record<string, object>
/**
* Optional opaque context carried by one renderSlot occurrence. Only
* function-valued members of the slot-level injected hooks compartment
* receive it; the slot machinery never interprets the value.
*/
hookContext?: unknown
/**
* Optional Slot-level inject face supplied by the parent registration's
* child declaration. Every registered entry receives its bound component
* face; child registrants do not own or replace this common capability.
*/
inject?: object
}
/**
* Runtime dispatch spec for one slot, recorded from a register call's
* `children` value. The literal is compile-time checked against the SlotMap
* entry (`SlotSpec<SlotMap[P]>` in {@link ChildrenDecl}), so type and value
* are declared at one point and validate each other.
* entry (`SlotSpec<SlotMap[P]>` in {@link ChildrenDecl}), so kind, scope, and
* any common inject face are declared at one point and validate each other.
*/
export interface SlotSpec<E extends SlotEntryDef> { kind: E['kind']; scope: E['scope'] }
export type SlotSpec<E extends SlotEntryDef> = {
kind: E['kind']
scope: E['scope']
} & ('inject' extends keyof E
? E extends { inject: infer Injected extends object }
? { inject: Injected }
: { inject?: object }
: { inject?: never })
/**
* Child-slot declaration table for register(): keys are the declared (and
@@ -123,6 +148,30 @@ export type ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec<SlotMap[P]
export type OwnerOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { owner: infer O extends object } ? O : object
/** Registration/dispatch key domain of one keyed slot. */
export type EntryKeyOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { kind: 'keyed'; keyProps: infer P extends object }
? keyof P & string
: string
/** Key-dependent props supplied by the owner at one keyed dispatch site. */
export type KeyPropsOf<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K>,
> = SlotMap[K] extends { kind: 'keyed'; keyProps: infer P extends object }
? EntryKey extends keyof P
? P[EntryKey] extends object ? P[EntryKey] : never
: never
: object
/** Opaque per-render occurrence context declared by one slot. */
export type HookContextOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { hookContext: infer Context } ? Context : never
/** Common render-occurrence inject face declared by one slot. */
export type SlotInjectOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { inject: infer Injected extends object } ? Injected : object
/** Scope axis of a slot key's SlotMap entry. */
export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K]['scope']
@@ -159,15 +208,26 @@ export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ?
* Runtime props share for a slot key: owner share (parent's renderSlot call
* site) + session standard kit (session scope only) + the global seat.
*/
export type PropsRuntime<K extends keyof SlotMap & string> =
export type PropsRuntime<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
> =
OwnerOf<K> &
KeyPropsOf<K, EntryKey> &
SlotInjectFace<SlotInjectOf<K>> &
(ScopeOf<K> extends 'session' ? SessionStandardProps
: ScopeOf<K> extends 'session-maybe' ? SessionMaybeStandardProps
: object) &
GlobalStandardProps
/** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
/** renderSlot dispatch options: keyed dispatch key, list filtering, and empty fallback. */
export interface RenderOpts<EntryKey extends string = string> {
entryKey?: EntryKey
only?: string
fallback?: ReactNode
/** Type-erased runtime seat; PropsRenderSlots narrows or removes it per slot declaration. */
hookContext?: unknown
}
/** renderSlotChain dispatch options. */
export interface ChainRenderOpts {
@@ -200,6 +260,40 @@ export type ChainSelect<O extends object, M> = (owner: O) => M | null
export type ChainKeysOf<S extends keyof SlotMap & string> =
S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never
/** Keys in a render share whose dispatch occurrence requires hookContext. */
type ContextualKeysOf<S extends keyof SlotMap & string> =
S extends unknown ? (SlotMap[S] extends { hookContext: unknown } ? S : never) : never
/** Keys in a render share with the ordinary optional options bag. */
type OrdinaryKeysOf<S extends keyof SlotMap & string> = Exclude<S, ContextualKeysOf<S>>
/**
* Plain and contextual child dispatch signatures. Keeping them as separate
* call signatures preserves ordinary renderSlot assignability while making a
* declared hookContext mandatory only for the Slot keys that need it.
*/
type RenderSlotFn<S extends keyof SlotMap & string> =
([ContextualKeysOf<S>] extends [never] ? object : {
<
K extends ContextualKeysOf<S>,
EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
>(
key: K,
owner: OwnerOf<K> & KeyPropsOf<K, NoInfer<EntryKey>>,
opts: RenderOpts<EntryKey> & { hookContext: HookContextOf<K> },
): ReactNode
}) &
([OrdinaryKeysOf<S>] extends [never] ? object : {
<
K extends OrdinaryKeysOf<S>,
EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
>(
key: K,
owner: OwnerOf<K> & KeyPropsOf<K, NoInfer<EntryKey>>,
opts?: Omit<RenderOpts<EntryKey>, 'hookContext'>,
): ReactNode
})
/**
* Chain matched share: a chain-slot component receives its selector's
* non-null result as the framework-injected `matched` prop; other kinds add
@@ -248,7 +342,7 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
* @param opts - kind dispatch options.
* @returns rendered node(s).
*/
renderSlot: <K extends Exclude<S, ChainKeysOf<S>>>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
renderSlot: RenderSlotFn<Exclude<S, ChainKeysOf<S>>>
readonly __renders?: ((key: S) => void) | undefined
} & ([ChainKeysOf<S>] extends [never] ? object : {
/**
@@ -277,19 +371,53 @@ export type SlotComponent<P> = (props: P) => ReactNode
/**
* Registrant hooks compartment: bare observable sources (getSnapshot +
* subscribe pairs) supplied under the reserved `hooks` key of an inject
* face. The registrant-private twin of the `sessions.provide` hooks
* compartment: the renderer binds each source into a `use<Name>` selector
* hook, so the sources never reach the component and plugin-private reactive
* facts ride the same subscription machinery as the standard kit instead of
* hand-rolled component subscriptions.
* subscribe pairs) supplied under the reserved `hooks` key of an entry's
* inject face. These retain the original source-to-selector binding and do
* not participate in render-occurrence context.
*/
export type HooksSources = Record<string, HostObservable<unknown>>
/** Framework-owned props visible while a slot-level contextual Hook is bound. */
export type StandardPropsOf<K extends keyof SlotMap & string> =
(ScopeOf<K> extends 'session' ? SessionStandardProps
: ScopeOf<K> extends 'session-maybe' ? SessionMaybeStandardProps
: object) &
GlobalStandardProps
/**
* One function-valued slot-level inject.hooks member. The factory is pure and
* returns the actual custom Hook; it must not invoke a Hook while being bound.
*/
export type SlotHookFactory<
K extends keyof SlotMap & string,
Hook extends (...args: never[]) => unknown,
> = (
standard: StandardPropsOf<K>,
hookContext: HookContextOf<K>,
) => Hook
/** Component-side Hook produced from one slot-level inject.hooks member. */
type BoundHookOf<Definition> =
Definition extends HostObservable<infer Snapshot>
? SnapshotSelectorHook<Snapshot>
: Definition extends (...args: never[]) => infer Hook
? Hook extends (...args: never[]) => unknown ? Hook : never
: never
/**
* Selector-hook share synthesized from a hooks compartment: each source
* `name` becomes a `use<Name>` selector hook over its snapshot type.
*/
export type PropsSlotHooks<HS extends object> = {
[N in keyof HS & string as `use${Capitalize<N>}`]:
BoundHookOf<HS[N]>
}
/** Component-side view of a slot dispatcher's common inject face. */
export type SlotInjectFace<I extends object> =
I extends { hooks: infer HS extends object } ? Omit<I, 'hooks'> & PropsSlotHooks<HS> : I
/** Selector-hook share synthesized from an entry inject hooks compartment. */
export type PropsHooks<HS extends HooksSources> = {
[N in keyof HS & string as `use${Capitalize<N>}`]:
SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
@@ -313,12 +441,13 @@ export type InjectFace<I extends object> =
*/
export type ComposedProps<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K>,
S extends keyof SlotMap & string,
H,
I extends object,
M = never,
N = undefined,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
> = PropsRuntime<K, EntryKey> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
/**
* Inject factory parameter list, derived from the registration's declaration:
@@ -345,12 +474,16 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
export type SlotLabel = string | (() => string)
/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */
export type KindOptions<E extends SlotEntryDef, M = never> =
E['kind'] extends 'keyed' ? { key: string }
: E['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel }
: E['kind'] extends 'chain' ? {
export type KindOptions<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K>,
M = never,
> =
SlotMap[K]['kind'] extends 'keyed' ? { key: EntryKey }
: SlotMap[K]['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel }
: SlotMap[K]['kind'] extends 'chain' ? {
/** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */
select: ChainSelect<E extends { owner: infer O extends object } ? O : object, M>
select: ChainSelect<SlotMap[K] extends { owner: infer O extends object } ? O : object, M>
/** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */
priority?: number
}
@@ -372,7 +505,14 @@ type RendersCheck<C, D> =
: unknown
/** Common register options share (see {@link SlotCore.register} for semantics). */
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never, N = undefined> = {
type BaseOptions<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K>,
D extends ChildrenDecl,
H,
M = never,
N = undefined,
> = {
/** Target slot key (the entry contributes INTO this slot). */
name: K
/** Child-slot declaration + render authorization + runtime spec, in one table. */
@@ -388,7 +528,7 @@ type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M
locale?: N
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K], M>
} & KindOptions<K, EntryKey, M>
/**
* One stored registration, as recorded by the core and read by the render
@@ -528,15 +668,16 @@ export class SlotCore {
* would lose the per-overload inference of I. */
register<
K extends keyof SlotMap & string,
const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M, N> & { inject?: undefined },
options: BaseOptions<K, EntryKey, D, H, M, N> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>>
& SlotComponent<ComposedProps<K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>>
& RendersCheck<C, D>,
): () => void
/**
@@ -552,15 +693,16 @@ export class SlotCore {
register<
K extends keyof SlotMap & string,
I extends object,
const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
options: BaseOptions<K, EntryKey, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>>
& SlotComponent<ComposedProps<K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>>
& RendersCheck<C, D>,
): () => void
/* jscpd:ignore-end */

View File

@@ -87,11 +87,13 @@ export interface SessionProvideInfo extends SessionMaybeProvideInfo {
hooks: Record<string, HostObservable<unknown>>
}
/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */
/** renderSlot dispatch options at the machinery level. */
export interface RenderOpts {
entryKey?: string
only?: string
fallback?: ReactNode
/** Opaque occurrence context consumed only by function-valued injected Hooks. */
hookContext?: unknown
}
/** Host surface the runtime SlotsService presents to the installed renderer. */