@dsh-ft/dsh-fft-settings 0.0.0-stage → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +395 -2
  3. package/cordis.patch.yml +18 -0
  4. package/docs/compatibility-dsh-0.1.6-alpha.1.md +58 -0
  5. package/docs/extension-service-design.md +351 -0
  6. package/docs/extension-service-sequences.html +545 -0
  7. package/docs/ftshare-settings-er.html +139 -0
  8. package/docs/ftshare-settings-er.svg +113 -0
  9. package/docs/ftshare-settings-results-er.html +148 -0
  10. package/docs/ftshare-settings-results-er.svg +122 -0
  11. package/docs/kline-integration.md +258 -0
  12. package/lib/agent.d.ts +24 -0
  13. package/lib/agent.js +26 -0
  14. package/lib/brand.d.ts +53 -0
  15. package/lib/brand.js +50 -0
  16. package/lib/client.d.ts +8 -0
  17. package/lib/client.js +806 -0
  18. package/lib/client.js.map +1 -0
  19. package/lib/contract.d.ts +14 -0
  20. package/lib/contract.js +14 -0
  21. package/lib/core/client-transport.d.ts +15 -0
  22. package/lib/core/client-transport.js +31 -0
  23. package/lib/core/contract.d.ts +62 -0
  24. package/lib/core/contract.js +28 -0
  25. package/lib/core/module-registry.d.ts +54 -0
  26. package/lib/core/module-registry.js +68 -0
  27. package/lib/core/rpc-core.d.ts +69 -0
  28. package/lib/core/rpc-core.js +88 -0
  29. package/lib/core/shell.d.ts +59 -0
  30. package/lib/core/shell.js +28 -0
  31. package/lib/core/styles.d.ts +31 -0
  32. package/lib/core/styles.js +43 -0
  33. package/lib/core/transport.d.ts +19 -0
  34. package/lib/core/transport.js +80 -0
  35. package/lib/default-preset.d.ts +52 -0
  36. package/lib/default-preset.js +121 -0
  37. package/lib/features/api-key/card.d.ts +24 -0
  38. package/lib/features/api-key/card.js +39 -0
  39. package/lib/features/api-key/client-api.d.ts +4 -0
  40. package/lib/features/api-key/client-api.js +11 -0
  41. package/lib/features/api-key/contract.d.ts +69 -0
  42. package/lib/features/api-key/contract.js +24 -0
  43. package/lib/features/api-key/host.d.ts +17 -0
  44. package/lib/features/api-key/host.js +32 -0
  45. package/lib/features/api-key/index.d.ts +13 -0
  46. package/lib/features/api-key/index.js +12 -0
  47. package/lib/features/api-key/logging.d.ts +15 -0
  48. package/lib/features/api-key/logging.js +1 -0
  49. package/lib/features/api-key/model.d.ts +24 -0
  50. package/lib/features/api-key/model.js +105 -0
  51. package/lib/features/api-key/probe.d.ts +17 -0
  52. package/lib/features/api-key/probe.js +148 -0
  53. package/lib/features/api-key/rpc.d.ts +4 -0
  54. package/lib/features/api-key/rpc.js +17 -0
  55. package/lib/features/api-key/service.d.ts +21 -0
  56. package/lib/features/api-key/service.js +181 -0
  57. package/lib/features/api-key/styles.d.ts +9 -0
  58. package/lib/features/api-key/styles.js +24 -0
  59. package/lib/features/logs/archive.d.ts +87 -0
  60. package/lib/features/logs/archive.js +156 -0
  61. package/lib/features/logs/card.d.ts +22 -0
  62. package/lib/features/logs/card.js +107 -0
  63. package/lib/features/logs/client-api.d.ts +4 -0
  64. package/lib/features/logs/client-api.js +11 -0
  65. package/lib/features/logs/collector.d.ts +91 -0
  66. package/lib/features/logs/collector.js +258 -0
  67. package/lib/features/logs/config.d.ts +70 -0
  68. package/lib/features/logs/config.js +97 -0
  69. package/lib/features/logs/contract.d.ts +130 -0
  70. package/lib/features/logs/contract.js +67 -0
  71. package/lib/features/logs/export.d.ts +92 -0
  72. package/lib/features/logs/export.js +212 -0
  73. package/lib/features/logs/format.d.ts +42 -0
  74. package/lib/features/logs/format.js +172 -0
  75. package/lib/features/logs/home.d.ts +18 -0
  76. package/lib/features/logs/home.js +39 -0
  77. package/lib/features/logs/host.d.ts +19 -0
  78. package/lib/features/logs/host.js +139 -0
  79. package/lib/features/logs/index.d.ts +12 -0
  80. package/lib/features/logs/index.js +12 -0
  81. package/lib/features/logs/rpc.d.ts +6 -0
  82. package/lib/features/logs/rpc.js +27 -0
  83. package/lib/features/logs/styles.d.ts +9 -0
  84. package/lib/features/logs/styles.js +13 -0
  85. package/lib/guidance.d.ts +41 -0
  86. package/lib/guidance.js +88 -0
  87. package/lib/index.d.ts +67 -0
  88. package/lib/index.js +50 -0
  89. package/lib/rpc.d.ts +10 -0
  90. package/lib/rpc.js +10 -0
  91. package/locale/en.json +6 -0
  92. package/locale/zh.json +6 -0
  93. package/package.json +108 -4
  94. package/src/agent.ts +31 -0
  95. package/src/brand.ts +79 -0
  96. package/src/client.tsx +21 -0
  97. package/src/contract.ts +14 -0
  98. package/src/core/client-transport.ts +37 -0
  99. package/src/core/contract.ts +57 -0
  100. package/src/core/module-registry.ts +102 -0
  101. package/src/core/rpc-core.ts +107 -0
  102. package/src/core/shell.tsx +81 -0
  103. package/src/core/styles.ts +43 -0
  104. package/src/core/transport.ts +82 -0
  105. package/src/default-preset.ts +151 -0
  106. package/src/features/api-key/card.tsx +77 -0
  107. package/src/features/api-key/client-api.ts +12 -0
  108. package/src/features/api-key/contract.ts +63 -0
  109. package/src/features/api-key/host.ts +36 -0
  110. package/src/features/api-key/index.ts +13 -0
  111. package/src/features/api-key/logging.ts +15 -0
  112. package/src/features/api-key/model.ts +63 -0
  113. package/src/features/api-key/probe.ts +125 -0
  114. package/src/features/api-key/rpc.ts +18 -0
  115. package/src/features/api-key/service.ts +157 -0
  116. package/src/features/api-key/styles.ts +24 -0
  117. package/src/features/logs/archive.ts +230 -0
  118. package/src/features/logs/card.tsx +169 -0
  119. package/src/features/logs/client-api.ts +20 -0
  120. package/src/features/logs/collector.ts +313 -0
  121. package/src/features/logs/config.ts +115 -0
  122. package/src/features/logs/contract.ts +149 -0
  123. package/src/features/logs/export.ts +274 -0
  124. package/src/features/logs/format.ts +188 -0
  125. package/src/features/logs/home.ts +43 -0
  126. package/src/features/logs/host.ts +153 -0
  127. package/src/features/logs/index.ts +12 -0
  128. package/src/features/logs/rpc.ts +29 -0
  129. package/src/features/logs/styles.ts +13 -0
  130. package/src/guidance.ts +114 -0
  131. package/src/index.ts +59 -0
  132. package/src/rpc.ts +10 -0
package/src/agent.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Agent-plane entry for the FT prompt contract.
3
+ *
4
+ * Mounted by an agent preset so the contract exists only for that preset's
5
+ * agents; the package's host entry keeps providing the settings service and
6
+ * must not be mounted from a preset (a service row there needs an isolate
7
+ * realm). The texts and the registration both live in `./guidance.ts`, so this
8
+ * entry adds no second copy.
9
+ * @module @dsh-ft/dsh-fft-settings/agent
10
+ */
11
+
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import { installGuidance } from './guidance.js'
14
+
15
+ /** Cordis plugin name. */
16
+ export const name = '@dsh-ft/dsh-fft-settings/agent'
17
+
18
+ /**
19
+ * The prompt registry this entry contributes to. `ftshareSettings` is optional
20
+ * and mounted in `installGuidance`, so a missing settings row costs only the
21
+ * credential fact instead of the whole contract.
22
+ */
23
+ export const inject = ['systemPrompt']
24
+
25
+ /**
26
+ * Register the FT prompt contract in the mounting scope.
27
+ * @param ctx - the preset subtree's context; the preset's agents are the scope.
28
+ */
29
+ export function apply(ctx: Context): void {
30
+ installGuidance(ctx)
31
+ }
package/src/brand.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * FT brand tokens published as a host theme override layer.
3
+ *
4
+ * The layer only *adds* brand variables on `body`; it never overrides a
5
+ * `--dsw-alias-*` value, so host chrome keeps its own palette and any user
6
+ * theme keeps working underneath. Consumers read `var(--ft-*)` with the same
7
+ * value as their fallback: the layer is a convenience, not a dependency, so
8
+ * every sub-plugin keeps its brand colors when this plugin is absent (or when
9
+ * the host has no `theme` service at all). Scheme-invariant tokens repeat one
10
+ * value; palette-aware tokens spell both with `light-dark()`.
11
+ *
12
+ * Keep those fallbacks equal to {@link FT_BRAND_TOKENS}; `tests/brand.test.mjs`
13
+ * scans every package for the pairing and fails on a drift.
14
+ */
15
+
16
+ /** One token value per palette mode; the host rejects a bare string. */
17
+ export interface BrandTokenModes {
18
+ readonly light: string
19
+ readonly dark: string
20
+ }
21
+
22
+ /** One value for both palettes: brand fills are scheme-invariant. */
23
+ const invariant = (value: string): BrandTokenModes => ({ light: value, dark: value })
24
+
25
+ /** Layer identity reported to `ctx.theme.overrideTokens` (one layer per source). */
26
+ export const FT_BRAND_SOURCE = '@dsh-ft/dsh-fft-settings'
27
+
28
+ /**
29
+ * The published brand colors.
30
+ *
31
+ * `accent` is the FT yellow used by this plugin's primary action and eyebrow;
32
+ * `accent-warm` is the orange trading accent shared by market and trade
33
+ * surfaces (it is the only palette-aware group); `wechat` is the enterprise-
34
+ * WeChat green the SSO card keeps as its identity. Inks are the text colors on
35
+ * those fills, and `-soft` is the tinted surface behind the accent.
36
+ */
37
+ export const FT_BRAND_TOKENS: Readonly<Record<string, BrandTokenModes>> = Object.freeze({
38
+ '--ft-accent': invariant('#f0c433'),
39
+ '--ft-accent-ink': invariant('#171717'),
40
+ '--ft-accent-warm': { light: '#cf5623', dark: '#ff8352' },
41
+ '--ft-accent-warm-hover': { light: '#b8481b', dark: '#ff9a72' },
42
+ '--ft-accent-warm-ink': { light: '#ffffff', dark: '#1a120e' },
43
+ '--ft-accent-warm-soft': { light: '#fbeee8', dark: '#2b1d16' },
44
+ '--ft-wechat': invariant('#2ebd68'),
45
+ '--ft-wechat-ink': invariant('#102419'),
46
+ })
47
+
48
+ /** The slice of the host theme service this layer uses. */
49
+ export interface BrandThemeService {
50
+ overrideTokens(source: string, tokens: Readonly<Record<string, BrandTokenModes>>): () => void
51
+ }
52
+
53
+ /** Scoped context delivered once the `theme` service is available. */
54
+ export interface BrandThemeScope {
55
+ readonly theme: BrandThemeService
56
+ effect(callback: () => () => void, label: string): void
57
+ }
58
+
59
+ /** The slice of the client context this layer needs: a deferred service inject. */
60
+ export interface BrandThemeContext {
61
+ inject(deps: readonly string[], callback: (scope: BrandThemeScope) => void): void
62
+ }
63
+
64
+ /**
65
+ * Stack the brand layer once the host theme service exists.
66
+ *
67
+ * The deferred `inject` is deliberate: this plugin's declared client context
68
+ * names only the services its cards read, and a host without `theme` must
69
+ * still load the settings page — the CSS fallbacks then carry the colors.
70
+ * @param ctx - client context carrying the host theme service.
71
+ */
72
+ export function applyBrandTokens(ctx: BrandThemeContext): void {
73
+ ctx.inject(['theme'], (scope) => {
74
+ scope.effect(
75
+ () => scope.theme.overrideTokens(FT_BRAND_SOURCE, FT_BRAND_TOKENS),
76
+ 'ft-settings: FT brand tokens',
77
+ )
78
+ })
79
+ }
package/src/client.tsx ADDED
@@ -0,0 +1,21 @@
1
+ import { registerApiKeyCard, type ApiKeyCardContext } from './features/api-key/card.js'
2
+ import { registerLogsCard } from './features/logs/card.js'
3
+ import { registerSettingsSection } from './core/shell.js'
4
+ import { applyBrandTokens, type BrandThemeContext } from './brand.js'
5
+ export * from './core/shell.js'
6
+ /** Browser context this plugin's own client half requires. */
7
+ export type FtshareSettingsClientContext = ApiKeyCardContext
8
+ export const inject = ['slots', 'remote']
9
+ export function apply(ctx: FtshareSettingsClientContext): void {
10
+ registerFtshareSettings(ctx)
11
+ // The declared context above names only the services the cards read; the
12
+ // brand layer defers on `theme` through the full Cordis context, so this
13
+ // plugin still loads on a host without the theme service.
14
+ applyBrandTokens(ctx as unknown as BrandThemeContext)
15
+ }
16
+ /** Contribute the section shell, then the built-in cards through the slot it declares. */
17
+ export function registerFtshareSettings(ctx: FtshareSettingsClientContext): void {
18
+ registerSettingsSection(ctx)
19
+ registerApiKeyCard(ctx)
20
+ registerLogsCard(ctx)
21
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@dsh-ft/dsh-fft-settings/contract` — the aggregate contract entry.
3
+ *
4
+ * Re-exports the generic seam contract plus every built-in feature contract,
5
+ * so `FtshareSettingsService`, the route constants and the API Key / logs
6
+ * vocabulary keep arriving from one import. It has no Node runtime dependency, so it is
7
+ * safe in a browser bundle and in a host plugin alike. Two type sources feed
8
+ * it: the Host contract (`./core/contract.js` plus each feature contract) and,
9
+ * for UI composition, `@dsh-ft/dsh-fft-settings/client`, which merges the card
10
+ * slot into the renderer's SlotMap.
11
+ */
12
+ export * from './core/contract.js'
13
+ export * from './features/api-key/contract.js'
14
+ export * from './features/logs/contract.js'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Browser half of the settings transport.
3
+ *
4
+ * Calls are same-origin fetches to the plugin route, so the browser session
5
+ * cookie carries authentication and this plugin needs no Connection RPC client.
6
+ */
7
+
8
+ import { FTSHARE_SETTINGS_ROUTE } from './contract.js'
9
+ import type { ClientTransport, RpcResult } from './rpc-core.js'
10
+
11
+ /** Transport this caller posts through; same signature as the global `fetch`. */
12
+ export type SettingsFetch = (input: string, init: RequestInit) => Promise<Response>
13
+
14
+ /**
15
+ * Create the browser-backed caller used by the settings cards.
16
+ * @param doFetch - transport override; defaults to the page's global fetch.
17
+ * @returns caller that owns request encoding and envelope decoding.
18
+ */
19
+ export function createSettingsTransport(doFetch?: SettingsFetch): ClientTransport {
20
+ const send: SettingsFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init))
21
+ return {
22
+ async call(endpoint, payload = {}, signal) {
23
+ const response = await send(FTSHARE_SETTINGS_ROUTE, {
24
+ method: 'POST',
25
+ headers: { 'content-type': 'application/json' },
26
+ body: JSON.stringify({ endpoint, payload }),
27
+ ...signal === undefined ? {} : { signal },
28
+ })
29
+ if (!response.ok) {
30
+ // The carrier answers 401/403 before dispatch; anything else is a route
31
+ // or plugin-level failure. Both are transport failures for the caller.
32
+ throw new Error(`transport failure for ${endpoint}: HTTP ${response.status}`)
33
+ }
34
+ return await response.json() as RpcResult<unknown>
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Generic half of the settings contract: the seam's identity, its transport
3
+ * constants, and the service shape. It carries no feature vocabulary — a
4
+ * feature's own contract lives beside that feature and merges into
5
+ * {@link FtshareSettingsNamespaces}.
6
+ */
7
+ import type { FtshareSettingsModuleRegistry } from './module-registry.js'
8
+ export type { FtshareSettingsModuleRegistry, SettingsModule, SettingsRegistration } from './module-registry.js'
9
+ export type { SettingsRpcMethod, SettingsRpcMethods } from './rpc-core.js'
10
+ /** Protocol version of the settings route and envelope. */
11
+ export const FTSHARE_SETTINGS_API_VERSION = 1 as const
12
+ /**
13
+ * Identity of the settings section that carries this plugin's cards.
14
+ *
15
+ * The id replaces the old `ftshare`. The label is a neutral product name, not
16
+ * the internal brand: the page ships publicly, and the data source it
17
+ * integrates is named inside the cards instead.
18
+ */
19
+ export const FTSHARE_SETTINGS_SECTION = { id: 'dsh-fft', order: 120, label: '金融终端' } as const
20
+ /**
21
+ * Child slot of {@link FTSHARE_SETTINGS_SECTION} that carries its cards.
22
+ *
23
+ * The section declares the slot when it registers; every card — ours and a
24
+ * third party's — contributes through it with `id`/`order`/`label`, and
25
+ * `order` (ascending, default 100) decides the card's position.
26
+ */
27
+ export const FTSHARE_SETTINGS_CARD_SLOT = 'ftshare.settings.card' as const
28
+ /**
29
+ * Browser-facing route for this plugin's settings calls.
30
+ *
31
+ * It sits below `/api` so the shared connection carrier applies the Host/Origin
32
+ * fence and browser authentication before dispatch; this plugin only handles
33
+ * the call itself.
34
+ */
35
+ export const FTSHARE_SETTINGS_ROUTE = '/api/ftshare-settings' as const
36
+ /** Largest request body accepted on {@link FTSHARE_SETTINGS_ROUTE}. */
37
+ export const FTSHARE_SETTINGS_MAX_BODY_BYTES = 64 * 1024
38
+ /**
39
+ * Merge point for namespaces a package reads but does not produce. A producer
40
+ * gets its type from `register` inference; a consumer that reads someone
41
+ * else's namespace declares one line, e.g. `cockpit: CockpitSettings`. Each
42
+ * built-in feature merges its own line from its own feature directory.
43
+ */
44
+ export interface FtshareSettingsNamespaces {}
45
+ /**
46
+ * Host service published as `ctx.ftshareSettings`.
47
+ *
48
+ * `apiVersion` and `modules` are permanent; every other member appears only
49
+ * while its namespace is registered, so consumers must read properties at call
50
+ * time instead of caching them during their own apply.
51
+ */
52
+ export type FtshareSettingsService =
53
+ & {
54
+ readonly apiVersion: typeof FTSHARE_SETTINGS_API_VERSION
55
+ readonly modules: FtshareSettingsModuleRegistry
56
+ }
57
+ & { readonly [N in keyof FtshareSettingsNamespaces & string]: FtshareSettingsNamespaces[N] }
@@ -0,0 +1,102 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { FTSHARE_SETTINGS_API_VERSION, type FtshareSettingsService } from './contract.js'
3
+ import { assertSettingsName, assertSettingsRpcModule, createSettingsRpcHandler, type SettingsRpcHandler, type SettingsRpcMethods } from './rpc-core.js'
4
+
5
+ /**
6
+ * One contribution to the settings service: a namespace, the host services it
7
+ * needs, and a synchronous factory for its service instance and RPC methods.
8
+ * Third parties may write this shape structurally; `defineSettingsModule` only
9
+ * buys type inference.
10
+ */
11
+ export interface SettingsModule<N extends string = string, S = unknown> {
12
+ /** Namespace: the RPC endpoint prefix and the service property name. */
13
+ readonly namespace: N
14
+ /** Host services this module resolves on its owner context at registration. */
15
+ readonly inject: readonly string[]
16
+ /** Register lifecycle cleanup through ctx.effect/on; never return browser secrets as RPC methods. */
17
+ create(ctx: Context): { service: S; rpcMethods: SettingsRpcMethods }
18
+ }
19
+ export function defineSettingsModule<const N extends string, S>(module: SettingsModule<N, S>): SettingsModule<N, S> { return module }
20
+ /** Handle returned by {@link FtshareSettingsModuleRegistry.register}. */
21
+ export interface SettingsRegistration<N extends string = string, S = unknown> {
22
+ readonly namespace: N
23
+ readonly service: S
24
+ /** Idempotent; also runs when the owner fiber releases. */
25
+ dispose(): void
26
+ }
27
+ /** Live namespace registry behind `ctx.ftshareSettings.modules`. */
28
+ export interface FtshareSettingsModuleRegistry {
29
+ register<const N extends string, S>(module: SettingsModule<N, S>, owner: Context): SettingsRegistration<N, S>
30
+ /** Namespaces currently published on the service object, in registration order. */
31
+ namespaces(): readonly string[]
32
+ }
33
+ /** The host half assembled by {@link createSettingsHost}. */
34
+ export interface FtshareSettingsHost {
35
+ /** Service object published as `ctx.ftshareSettings`; namespaces appear on it at runtime. */
36
+ readonly service: FtshareSettingsService
37
+ /** Live dispatch table; mount it once and let namespaces come and go. */
38
+ readonly handler: SettingsRpcHandler
39
+ }
40
+
41
+ /**
42
+ * Assemble the host half of the seam: one service object plus one live route
43
+ * table that namespaces register into.
44
+ *
45
+ * Registration is validate-then-write: a bad namespace, a reserved name, a
46
+ * duplicate, or an unresolvable `module.inject` entry throws before `create`
47
+ * runs and leaves no service property or route behind. A successful
48
+ * registration installs a release effect on the owner fiber, so unloading the
49
+ * owner removes the service property and every route of that namespace
50
+ * together; the returned `dispose()` is idempotent and equivalent.
51
+ * @returns the service object and its route table.
52
+ */
53
+ export function createSettingsHost(): FtshareSettingsHost {
54
+ const handler = createSettingsRpcHandler()
55
+ /** Namespaces published by this host, in registration order. */
56
+ const published = new Map<string, SettingsRegistration<string, unknown>>()
57
+ const target: Record<string, unknown> = { apiVersion: FTSHARE_SETTINGS_API_VERSION }
58
+
59
+ function register<N extends string, S>(module: SettingsModule<N, S>, owner: Context): SettingsRegistration<N, S> {
60
+ // Validate everything knowable before the first write. `rpcMethods` exists
61
+ // only after `create`, so its endpoint names are checked immediately after,
62
+ // still before this registration publishes anything.
63
+ assertSettingsName(module.namespace)
64
+ if (published.has(module.namespace)) throw new Error(`Duplicate settings namespace: ${module.namespace}`)
65
+ for (const name of module.inject) {
66
+ if (owner.get(name) === undefined) throw new Error(`Missing settings module dependency for ${module.namespace}: ${name}`)
67
+ }
68
+ let released = false
69
+ let committed = false
70
+ let instance: S | undefined
71
+ const release = (): void => {
72
+ if (released) return
73
+ released = true
74
+ if (!committed) return
75
+ if (target[module.namespace] === instance) delete target[module.namespace]
76
+ if (published.get(module.namespace) === registration) {
77
+ published.delete(module.namespace)
78
+ handler.unregister(module.namespace)
79
+ }
80
+ }
81
+ const registration: SettingsRegistration<N, S> = {
82
+ namespace: module.namespace,
83
+ get service() { return instance as S },
84
+ dispose() { release() },
85
+ }
86
+ // Install the release effect first: an inactive owner then fails before
87
+ // this module allocates anything of its own.
88
+ owner.effect(() => release, `dsh-fft-settings: namespace ${module.namespace}`)
89
+ const created = module.create(owner)
90
+ instance = created.service
91
+ assertSettingsRpcModule({ namespace: module.namespace, rpcMethods: created.rpcMethods })
92
+ handler.register({ namespace: module.namespace, rpcMethods: created.rpcMethods })
93
+ Object.defineProperty(target, module.namespace, { value: created.service, enumerable: true, writable: true, configurable: true })
94
+ published.set(module.namespace, registration as SettingsRegistration<string, unknown>)
95
+ committed = true
96
+ return registration
97
+ }
98
+
99
+ const registry: FtshareSettingsModuleRegistry = { register, namespaces: () => [...published.keys()] }
100
+ target.modules = registry
101
+ return { service: target as unknown as FtshareSettingsService, handler }
102
+ }
@@ -0,0 +1,107 @@
1
+ import type { FTSHARE_SETTINGS_ROUTE } from './contract.js'
2
+ export interface RpcResult<T> { ok: boolean; value?: T; error?: { code: string; message: string; details: Record<string, unknown> } }
3
+ /** Browser caller for this plugin's route. */
4
+ export interface ClientTransport { call(endpoint: string, payload?: unknown, signal?: AbortSignal): Promise<RpcResult<unknown>> }
5
+ export type RpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<RpcResult<unknown>>
6
+ /** Structural shape of one exact Fetch route; `connection.fetch.register` accepts it. */
7
+ export interface SettingsRoute {
8
+ readonly path: typeof FTSHARE_SETTINGS_ROUTE
9
+ readonly methods: readonly string[]
10
+ readonly requestBody: 'buffered' | 'streaming'
11
+ readonly fetch: (request: Request) => Promise<Response>
12
+ }
13
+ /** Host registry for exact Fetch routes on the shared `/api` carrier. */
14
+ export interface HostFetch { register(route: SettingsRoute): () => Promise<void> }
15
+ export interface SettingsRpcMethod {
16
+ validate(payload: unknown): boolean
17
+ invoke(payload: unknown, signal: AbortSignal): unknown | Promise<unknown>
18
+ }
19
+ export type SettingsRpcMethods = Readonly<Record<string, SettingsRpcMethod>>
20
+ export interface SettingsRpcModule { namespace: string; rpcMethods: SettingsRpcMethods }
21
+ /**
22
+ * Live dispatch table behind {@link createSettingsRpcHandler}: routes appear
23
+ * when a namespace is registered and disappear when it is released, so the one
24
+ * route mounted at boot keeps serving namespaces that arrive later.
25
+ */
26
+ export interface SettingsRpcTable {
27
+ /** Validate one whole module, then publish its routes; a failure writes nothing. */
28
+ register(module: SettingsRpcModule): void
29
+ /** Remove one namespace and every route under it; unknown namespaces are a no-op. */
30
+ unregister(namespace: string): void
31
+ /** Namespaces currently owning at least one route, in registration order. */
32
+ namespaces(): readonly string[]
33
+ }
34
+ /** The dispatcher callable plus the increment/decrement surface of its route table. */
35
+ export type SettingsRpcHandler = RpcHandler & SettingsRpcTable
36
+ const fail = (code: string, message: string): RpcResult<unknown> => ({ ok: false, error: { code, message, details: {} } })
37
+ /** Names the service object and the route table reserve for themselves. */
38
+ export const SETTINGS_RESERVED_NAMES = ['apiVersion', 'modules', 'constructor', 'prototype', '__proto__'] as const
39
+ export function assertSettingsName(name: string): void {
40
+ if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(name) || (SETTINGS_RESERVED_NAMES as readonly string[]).includes(name)) throw new Error(`Invalid settings name: ${name}`)
41
+ }
42
+ /** Validate every endpoint part of one module without writing to any table. */
43
+ export function assertSettingsRpcModule(module: SettingsRpcModule): void {
44
+ assertSettingsName(module.namespace)
45
+ for (const name of Object.keys(module.rpcMethods)) assertSettingsName(name)
46
+ }
47
+ /**
48
+ * Build the live dispatcher for this plugin's route.
49
+ *
50
+ * Only explicitly registered methods are callable; never reflects over a
51
+ * service. The returned function carries its own route table, so the caller
52
+ * mounts the route once and mutates through `register` / `unregister`
53
+ * afterwards. Each `register` validates the whole module first — namespace
54
+ * format, reserved names, duplicates, and every method name — and writes
55
+ * nothing when any check fails.
56
+ * @param modules - optional initial namespaces, registered in order.
57
+ * @returns dispatcher with the live route table attached.
58
+ */
59
+ export function createSettingsRpcHandler(modules: readonly SettingsRpcModule[] = []): SettingsRpcHandler {
60
+ const routes = new Map<string, SettingsRpcMethod>()
61
+ const namespaces = new Set<string>()
62
+ const register = (module: SettingsRpcModule): void => {
63
+ assertSettingsRpcModule(module)
64
+ if (namespaces.has(module.namespace)) throw new Error(`Duplicate settings namespace: ${module.namespace}`)
65
+ // Committed: every check above ran before the first write.
66
+ namespaces.add(module.namespace)
67
+ for (const [name, method] of Object.entries(module.rpcMethods)) routes.set(`${module.namespace}/${name}`, method)
68
+ }
69
+ const unregister = (namespace: string): void => {
70
+ if (!namespaces.delete(namespace)) return
71
+ const prefix = `${namespace}/`
72
+ for (const endpoint of [...routes.keys()]) if (endpoint.startsWith(prefix)) routes.delete(endpoint)
73
+ }
74
+ const handler = (async (endpoint: string, payload: unknown, signal: AbortSignal): Promise<RpcResult<unknown>> => {
75
+ const method = routes.get(endpoint)
76
+ if (!method) return fail('not_found', '接口不存在')
77
+ if (signal.aborted) return fail('cancelled', '请求已取消')
78
+ try {
79
+ if (!method.validate(payload)) return fail('bad_request', '请求参数不正确')
80
+ return { ok: true, value: (await method.invoke(payload, signal)) ?? null }
81
+ } catch { return fail('operation_failed', '操作失败,请刷新状态后重试') }
82
+ }) as SettingsRpcHandler
83
+ handler.register = register
84
+ handler.unregister = unregister
85
+ handler.namespaces = () => [...namespaces]
86
+ for (const module of modules) register(module)
87
+ return handler
88
+ }
89
+ /**
90
+ * 把一次失败的信封翻译成调用方能展示的原因。
91
+ *
92
+ * `not_found` 是「浏览器半边比宿主半边新」的可观测症状:升级插件后客户端 bundle 由磁盘重新
93
+ * 加载,而宿主进程仍持有旧包,它没有新命名空间。此时重试永远不会成功,必须点名重启宿主。
94
+ * @param endpoint - 这次调用的 `<namespace>/<method>`。
95
+ * @param error - 宿主回传的失败原因,畸形信封下可能缺省。
96
+ * @returns 面向用户的失败描述。
97
+ */
98
+ function describeSettingsFailure(endpoint: string, error: RpcResult<unknown>['error']): string {
99
+ if (error?.code === 'not_found') return `FTShare 设置接口 ${endpoint} 不存在:宿主插件还是升级前的版本,重启 DSH 后再试`
100
+ return `FTShare 设置操作失败(${error?.code ?? 'unknown'}):${error?.message ?? '宿主未说明原因'}`
101
+ }
102
+ export async function callSettingsRpc<T>(transport: ClientTransport, namespace: string, method: string, payload: unknown = {}): Promise<T> {
103
+ const endpoint = `${namespace}/${method}`
104
+ const result = await transport.call(endpoint, payload)
105
+ if (!result.ok) throw new Error(describeSettingsFailure(endpoint, result.error))
106
+ return result.value as T
107
+ }
@@ -0,0 +1,81 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { PropsRenderSlots, PropsRuntime, SlotCore, SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
3
+ // Type-only: pulls the `settings.section` SlotMap entry into this program.
4
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
5
+ import { FTSHARE_SETTINGS_CARD_SLOT, FTSHARE_SETTINGS_SECTION } from './contract.js'
6
+ import { SETTINGS_CSS } from './styles.js'
7
+
8
+ /** Section slot this seam contributes into; declared by the settings shell. */
9
+ const FTSHARE_SETTINGS_SECTION_NAME = 'settings.section' as const
10
+
11
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
12
+ interface SlotMap {
13
+ /** One card inside the FTShare settings section; `order` sets its position. */
14
+ 'ftshare.settings.card': {
15
+ kind: 'list'
16
+ scope: 'root'
17
+ owner: FtshareSettingsCardOwnerProps
18
+ }
19
+ }
20
+ }
21
+ /** Owner share of a card: the section supplies no card-specific props. */
22
+ export interface FtshareSettingsCardOwnerProps {
23
+ /** Marker field: card owner props are intentionally empty. */
24
+ children?: never
25
+ }
26
+ /**
27
+ * The slice of the renderer-owned slots service this seam uses.
28
+ *
29
+ * `register` IS the ui-slots registration signature (the runtime registry
30
+ * reuses it verbatim, adding disposal through the caller's fiber); `inject`
31
+ * is the runtime wrapper's declaration-lifetime hook, which is why it is
32
+ * mirrored structurally here.
33
+ */
34
+ export interface FtshareSettingsSlotsService {
35
+ readonly register: SlotCore['register']
36
+ /** Run `callback` for each declaration lifetime of `key`. */
37
+ inject(key: keyof SlotMap & string, callback: () => () => void): () => void
38
+ }
39
+ /**
40
+ * Browser context the section shell requires: the renderer's slots service.
41
+ * A card that needs more (its own services, invalidation events) declares the
42
+ * extra members on its own context type.
43
+ */
44
+ export interface FtshareSettingsShellContext {
45
+ slots: FtshareSettingsSlotsService
46
+ }
47
+ /** Composed props of the section entry: the shell's owner share plus its declared child render. */
48
+ export type FtshareSettingsSectionProps =
49
+ & PropsRuntime<typeof FTSHARE_SETTINGS_SECTION_NAME>
50
+ & PropsRenderSlots<typeof FTSHARE_SETTINGS_CARD_SLOT>
51
+
52
+ /**
53
+ * The settings section shell: core CSS variables, the section chrome, and the
54
+ * single render site every card appears at. Cards own everything inside it.
55
+ */
56
+ export function FtshareSettingsSection({ renderSlot }: FtshareSettingsSectionProps): ReactNode {
57
+ return <>
58
+ <style>{SETTINGS_CSS}</style>
59
+ {renderSlot(FTSHARE_SETTINGS_CARD_SLOT, {})}
60
+ </>
61
+ }
62
+
63
+ /**
64
+ * Declare the card slot and contribute the section that renders it.
65
+ *
66
+ * Declaring the child slot is what authorizes the render site; a card
67
+ * registering through {@link FTSHARE_SETTINGS_CARD_SLOT} before this runs waits
68
+ * for the declaration instead of failing.
69
+ * @param ctx - client context carrying the renderer's slots service.
70
+ * @returns idempotent disposer for the section and its declaration.
71
+ */
72
+ export function registerSettingsSection(ctx: FtshareSettingsShellContext): () => void {
73
+ return ctx.slots.inject(FTSHARE_SETTINGS_SECTION_NAME, () => ctx.slots.register(
74
+ {
75
+ name: FTSHARE_SETTINGS_SECTION_NAME,
76
+ ...FTSHARE_SETTINGS_SECTION,
77
+ children: { [FTSHARE_SETTINGS_CARD_SLOT]: { kind: 'list', scope: 'root' } },
78
+ },
79
+ FtshareSettingsSection,
80
+ ))
81
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The section shell's styles plus the theme tokens every card reads.
3
+ *
4
+ * The shell owns the container, typography, focus ring and link rules that
5
+ * every card sits inside; a card owns its own CSS and renders it from its own
6
+ * component, and core never styles card internals.
7
+ *
8
+ * The theme layer declares the plugin's `--ftshare-*` tokens once, on
9
+ * `.ftshare-settings` — the root element every card renders. Surfaces and text
10
+ * take their value from the host palette: `--dsw-alias-*` is what DSH
11
+ * publishes for the light, the dark and every registered theme, so one
12
+ * stylesheet follows the live palette with no script, no `prefers-color-scheme`
13
+ * copy and no second palette to keep in sync. The literal inside each `var()`
14
+ * is the fallback for a host that publishes no palette.
15
+ *
16
+ * The declarations sit on the card root rather than on `:root` because a
17
+ * custom property resolves where it is *declared*: `--dsw-alias-*` exists from
18
+ * `body` downwards, so a value derived from it above `body` would resolve to
19
+ * nothing and fall back. Consequence: the place to pin a look is the host
20
+ * palette itself (`ctx.theme.register` / `ctx.theme.overrideTokens`), not these
21
+ * names.
22
+ *
23
+ * `--ftshare-accent*` is the plugin's own brand and has no host equivalent. The
24
+ * values come from the FT brand layer's `--ft-*` tokens (`src/brand.ts`), with
25
+ * the same literal as the fallback so the card keeps its colors when that layer
26
+ * is absent. The fill and its ink are scheme-invariant;
27
+ * `--ftshare-accent-on-surface` mixes the same yellow towards the host's text
28
+ * colour, so the brand stays legible as the 11px eyebrow and as the focus ring
29
+ * on either surface.
30
+ */
31
+ export const SETTINGS_CSS = `
32
+ .ftshare-settings { --ftshare-text:var(--dsw-alias-label-primary,#f4f4f5); --ftshare-muted:var(--dsw-alias-label-secondary,#aeb2bd); --ftshare-panel:var(--dsw-alias-settings-card-fill,#292a30); --ftshare-border:var(--dsw-alias-settings-card-stroke,#44464f); --ftshare-control:var(--dsw-alias-border-l4,#696c77); --ftshare-input:var(--dsw-alias-bg-layer-1,#1d1e23); --ftshare-link:var(--dsw-alias-link,#d8c4fc); --ftshare-error:var(--dsw-alias-state-error-primary,#ffb5ad); --ftshare-accent:var(--ft-accent,#f0c433); --ftshare-accent-ink:var(--ft-accent-ink,#171717); --ftshare-accent-on-surface:color-mix(in srgb,var(--ftshare-accent) 50%,var(--dsw-alias-label-primary,#f4f4f5)); }
33
+ .ftshare-settings { color:var(--ftshare-text); max-width:760px; font-size:14px; line-height:1.6; }
34
+ .ftshare-settings * { box-sizing:border-box; }
35
+ .ftshare-settings h2 { font-size:26px; margin:4px 0 8px; }
36
+ .ftshare-settings h3 { font-size:15px; margin:0 0 12px; }
37
+ .ftshare-settings p { margin:8px 0; color:var(--ftshare-muted); }
38
+ .ftshare-settings header { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; margin-bottom:20px; }
39
+ .ftshare-settings small { display:block; color:var(--ftshare-muted); font-size:12px; margin:5px 0 12px; }
40
+ .ftshare-settings :is(button,input,a):focus-visible { outline:2px solid var(--ftshare-accent-on-surface); outline-offset:3px; }
41
+ .ftshare-settings a { color:var(--ftshare-link); }
42
+ @media(max-width:540px) { .ftshare-settings header { flex-direction:column; gap:10px; } }
43
+ `
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Host half of the settings transport.
3
+ *
4
+ * One exact route on the shared `/api` carrier: the carrier owns the Host/Origin
5
+ * fence and browser authentication, so this layer only decodes the call, bounds
6
+ * the body and returns the result envelope. Registering through
7
+ * `connection.rpc.handle` instead would read `webServer` through the context
8
+ * property proxy, whose resolution is ancestor-only and therefore fails for a
9
+ * plugin mounted beside the Web server.
10
+ */
11
+
12
+ import { FTSHARE_SETTINGS_MAX_BODY_BYTES, FTSHARE_SETTINGS_ROUTE } from './contract.js'
13
+ import type { RpcHandler, RpcResult, SettingsRoute } from './rpc-core.js'
14
+
15
+ const invalidRequest = (): RpcResult<unknown> => ({
16
+ ok: false,
17
+ error: { code: 'bad_request', message: '请求参数不正确', details: {} },
18
+ })
19
+
20
+ /**
21
+ * Build the exact Fetch route served by the connection carrier.
22
+ * @param options - the settings call handler.
23
+ * @returns route definition ready for `connection.fetch.register`.
24
+ */
25
+ export function createSettingsRoute({ handler }: { readonly handler: RpcHandler }): SettingsRoute {
26
+ return {
27
+ path: FTSHARE_SETTINGS_ROUTE,
28
+ methods: ['POST'],
29
+ requestBody: 'buffered',
30
+ fetch: async request => {
31
+ const body = await readJsonBody(request)
32
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
33
+ return json(400, invalidRequest())
34
+ }
35
+ const call = body as { readonly endpoint?: unknown; readonly payload?: unknown }
36
+ const endpoint = typeof call.endpoint === 'string' ? call.endpoint : ''
37
+ return json(200, await handler(endpoint, call.payload, request.signal))
38
+ },
39
+ }
40
+ }
41
+
42
+ /** Read and parse the JSON body; oversized, malformed or interrupted reads return undefined. */
43
+ async function readJsonBody(request: Request): Promise<unknown> {
44
+ const reader = request.body?.getReader()
45
+ if (reader === undefined) return undefined
46
+ const chunks: Uint8Array[] = []
47
+ let size = 0
48
+ try {
49
+ for (;;) {
50
+ const { done, value } = await reader.read()
51
+ if (done) break
52
+ size += value.byteLength
53
+ if (size > FTSHARE_SETTINGS_MAX_BODY_BYTES) {
54
+ await reader.cancel()
55
+ return undefined
56
+ }
57
+ chunks.push(value)
58
+ }
59
+ } catch {
60
+ // A client-side abort only means this request lost its body; nothing else to read.
61
+ return undefined
62
+ }
63
+ const bytes = new Uint8Array(size)
64
+ let offset = 0
65
+ for (const chunk of chunks) {
66
+ bytes.set(chunk, offset)
67
+ offset += chunk.byteLength
68
+ }
69
+ try {
70
+ return JSON.parse(new TextDecoder().decode(bytes))
71
+ } catch {
72
+ // A non-JSON body has the same single outcome: invalid request parameters.
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ function json(status: number, payload: unknown): Response {
78
+ return new Response(JSON.stringify(payload), {
79
+ status,
80
+ headers: { 'content-type': 'application/json' },
81
+ })
82
+ }