@_daniel_jiang/ai-host 0.1.0

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/src/store.ts ADDED
@@ -0,0 +1,327 @@
1
+ import type {
2
+ HostAnchorRef,
3
+ HostHintPlacement,
4
+ HostPageAction,
5
+ HostPageContext,
6
+ HostRefreshPayload,
7
+ RegisterPageOptions,
8
+ } from './types'
9
+ import type { SerializedPageAction, WidgetPayload } from './messages'
10
+ import { cloneForPostMessage } from './messages'
11
+
12
+ function resolveMaybe<T>(v: T | (() => T)): T {
13
+ return typeof v === 'function' ? (v as () => T)() : v
14
+ }
15
+
16
+ export type PrimaryPageHint = {
17
+ pageId: string
18
+ actionId: string
19
+ hint: string
20
+ placement: HostHintPlacement
21
+ }
22
+
23
+ type PageRefreshBinding = {
24
+ refreshKey: string
25
+ onRefresh: (payload?: HostRefreshPayload) => void
26
+ }
27
+
28
+ const pageActions: Record<string, HostPageAction[]> = {}
29
+ const pageRefresh: Record<string, PageRefreshBinding> = {}
30
+ /** 宿主会话级上下文(系统 / 名称 / 描述 / 菜单;由 init.getHostContext 按需提供) */
31
+ let hostContext: HostPageContext = {}
32
+ let currentPageId: string | null = null
33
+ let hintDismissed = false
34
+ /** 当前页、hintPlacement=anchor 且焦点在该 action 锚点内 */
35
+ let anchorFocusedActionId: string | null = null
36
+ let hintUiListener: (() => void) | null = null
37
+ let docFocusBound = false
38
+
39
+ export function setHintUiListener(fn: (() => void) | null) {
40
+ hintUiListener = fn
41
+ }
42
+
43
+ function notifyHintUi() {
44
+ try {
45
+ hintUiListener?.()
46
+ } catch {
47
+ /* ignore */
48
+ }
49
+ }
50
+
51
+ export function resolveAnchorElement(anchor: HostPageAction['anchor']): HTMLElement | null {
52
+ if (!anchor) return null
53
+ const raw = typeof anchor === 'function' ? anchor() : anchor
54
+ if (!raw) return null
55
+ let el: Element | null = null
56
+ if (raw instanceof Element) {
57
+ el = raw
58
+ } else if (typeof raw === 'object' && raw.$el instanceof Element) {
59
+ el = raw.$el
60
+ }
61
+ if (!el) return null
62
+ const inner = el.querySelector?.('textarea, input, [contenteditable="true"]')
63
+ return (inner instanceof HTMLElement ? inner : el) as HTMLElement
64
+ }
65
+
66
+ function isHintNode(node: Node | null): boolean {
67
+ if (!node || typeof (node as Element).closest !== 'function') return false
68
+ return Boolean((node as Element).closest('#agent-host-launcher-hint'))
69
+ }
70
+
71
+ function actionWantsAnchorHint(action: HostPageAction): boolean {
72
+ return Boolean(action.anchor) && action.hintPlacement === 'anchor'
73
+ }
74
+
75
+ function syncAnchorFocusFromTarget(target: EventTarget | null, related?: EventTarget | null) {
76
+ if (!currentPageId) {
77
+ if (anchorFocusedActionId) {
78
+ anchorFocusedActionId = null
79
+ notifyHintUi()
80
+ }
81
+ return
82
+ }
83
+ if (isHintNode(target as Node) || isHintNode(related as Node)) return
84
+ const actions = pageActions[currentPageId] || []
85
+ let next: string | null = null
86
+ if (target instanceof Node) {
87
+ for (const a of actions) {
88
+ if (!actionWantsAnchorHint(a)) continue
89
+ const el = resolveAnchorElement(a.anchor)
90
+ if (el && el.contains(target)) {
91
+ next = a.id
92
+ break
93
+ }
94
+ }
95
+ }
96
+ if (next === anchorFocusedActionId) return
97
+ anchorFocusedActionId = next
98
+ notifyHintUi()
99
+ }
100
+
101
+ function onDocFocusIn(ev: FocusEvent) {
102
+ syncAnchorFocusFromTarget(ev.target)
103
+ }
104
+
105
+ function onDocFocusOut(ev: FocusEvent) {
106
+ if (isHintNode(ev.relatedTarget as Node)) return
107
+ const t = ev.target
108
+ if (!(t instanceof Node) || !currentPageId) return
109
+ const actions = pageActions[currentPageId] || []
110
+ const focused = actions.find((a) => a.id === anchorFocusedActionId)
111
+ if (!focused) return
112
+ const el = resolveAnchorElement(focused.anchor)
113
+ if (el && el.contains(t) && !el.contains(ev.relatedTarget as Node)) {
114
+ anchorFocusedActionId = null
115
+ notifyHintUi()
116
+ }
117
+ }
118
+
119
+ function ensureDocFocusListeners() {
120
+ if (docFocusBound || typeof document === 'undefined') return
121
+ document.addEventListener('focusin', onDocFocusIn, true)
122
+ document.addEventListener('focusout', onDocFocusOut, true)
123
+ docFocusBound = true
124
+ }
125
+
126
+ function inferRefreshKey(actions: HostPageAction[], explicit?: string): string | undefined {
127
+ const key = (explicit || '').trim()
128
+ if (key) return key
129
+ for (const a of actions) {
130
+ if (!a.hostContext) continue
131
+ const hc = resolveMaybe(a.hostContext)
132
+ const refresh = hc && typeof hc === 'object' ? String((hc as { refresh?: unknown }).refresh || '').trim() : ''
133
+ if (refresh) return refresh
134
+ }
135
+ return undefined
136
+ }
137
+
138
+ export function isDirectHostAction(action: Pick<HostPageAction, 'kind'> | Pick<SerializedPageAction, 'kind'> | null | undefined): boolean {
139
+ return (action?.kind || 'widget') === 'direct'
140
+ }
141
+
142
+ export function registerHostPage(
143
+ pageId: string,
144
+ actions: HostPageAction[],
145
+ options: RegisterPageOptions = {},
146
+ ) {
147
+ ensureDocFocusListeners()
148
+ const pageChanged = pageId !== currentPageId
149
+ if (pageChanged) {
150
+ hintDismissed = false
151
+ anchorFocusedActionId = null
152
+ }
153
+ pageActions[pageId] = actions
154
+ currentPageId = pageId
155
+ if (!pageChanged && actions.some((a) => !a.enabled || a.enabled())) {
156
+ hintDismissed = false
157
+ }
158
+
159
+ const refreshKey = inferRefreshKey(actions, options.refreshKey)
160
+ if (refreshKey && typeof options.onRefresh === 'function') {
161
+ pageRefresh[pageId] = { refreshKey, onRefresh: options.onRefresh }
162
+ } else {
163
+ delete pageRefresh[pageId]
164
+ }
165
+ notifyHintUi()
166
+ }
167
+
168
+ export function unregisterHostPage(pageId: string) {
169
+ delete pageActions[pageId]
170
+ delete pageRefresh[pageId]
171
+ if (currentPageId === pageId) {
172
+ currentPageId = null
173
+ anchorFocusedActionId = null
174
+ }
175
+ notifyHintUi()
176
+ }
177
+
178
+ function sanitizeHostContext(raw: HostPageContext): HostPageContext {
179
+ const next = cloneForPostMessage(raw || {})
180
+ for (const key of Object.keys(next)) {
181
+ const val = next[key]
182
+ if (val == null || (typeof val === 'string' && !val.trim())) {
183
+ delete next[key]
184
+ }
185
+ }
186
+ return next
187
+ }
188
+
189
+ /** 整份写入宿主会话上下文(按需拉取后回写;离开专病时不应残留旧 name) */
190
+ export function replaceHostContext(partial: HostPageContext): HostPageContext {
191
+ hostContext = sanitizeHostContext(partial || {})
192
+ return { ...hostContext }
193
+ }
194
+
195
+ export function getHostContext(): HostPageContext {
196
+ return { ...hostContext }
197
+ }
198
+
199
+ export function clearHostContext() {
200
+ hostContext = {}
201
+ }
202
+
203
+ /** 任务完成:仅对仍注册且 refreshKey 匹配的页面触发刷新 */
204
+ export function dispatchRegisteredPageRefresh(refreshKey: unknown, payload?: HostRefreshPayload) {
205
+ const key = String(refreshKey || '').trim()
206
+ if (!key) return
207
+ for (const binding of Object.values(pageRefresh)) {
208
+ if (binding.refreshKey !== key) continue
209
+ try {
210
+ binding.onRefresh(payload)
211
+ } catch {
212
+ /* 业务刷新失败不阻断完成回调 */
213
+ }
214
+ }
215
+ }
216
+
217
+ export function clearHostPageStore() {
218
+ for (const id of Object.keys(pageActions)) delete pageActions[id]
219
+ for (const id of Object.keys(pageRefresh)) delete pageRefresh[id]
220
+ hostContext = {}
221
+ currentPageId = null
222
+ hintDismissed = false
223
+ anchorFocusedActionId = null
224
+ notifyHintUi()
225
+ }
226
+
227
+ export function setCurrentHostPage(pageId: string | null) {
228
+ if (pageId !== currentPageId) {
229
+ hintDismissed = false
230
+ anchorFocusedActionId = null
231
+ }
232
+ currentPageId = pageId
233
+ notifyHintUi()
234
+ }
235
+
236
+ export function dismissCurrentPageHint() {
237
+ hintDismissed = true
238
+ }
239
+
240
+ export function serializePageActions(actions: HostPageAction[]): SerializedPageAction[] {
241
+ return actions.map((a) => ({
242
+ id: a.id,
243
+ workflow: a.workflow || a.skill || '',
244
+ label: a.label,
245
+ host: a.host,
246
+ hint: a.hint ? resolveMaybe(a.hint) : undefined,
247
+ hostContext: a.hostContext ? cloneForPostMessage(resolveMaybe(a.hostContext)) : undefined,
248
+ prefilled: a.prefilled ? cloneForPostMessage(resolveMaybe(a.prefilled)) : undefined,
249
+ enabled: a.enabled ? a.enabled() : true,
250
+ kind: a.kind,
251
+ skill: a.skill,
252
+ noTools: a.noTools === true ? true : undefined,
253
+ }))
254
+ }
255
+
256
+ export function buildActionLaunchPayload(pageId: string, actionId: string): {
257
+ action: HostPageAction
258
+ prefilled?: Record<string, unknown>
259
+ hostContext?: Record<string, unknown>
260
+ } | null {
261
+ const action = (pageActions[pageId] || []).find((a) => a.id === actionId)
262
+ if (!action) return null
263
+ if (action.enabled && !action.enabled()) return null
264
+ const prefilled = action.prefilled ? cloneForPostMessage(resolveMaybe(action.prefilled)) : undefined
265
+ return {
266
+ action,
267
+ prefilled,
268
+ hostContext: action.hostContext ? cloneForPostMessage(resolveMaybe(action.hostContext)) : undefined,
269
+ }
270
+ }
271
+
272
+ export function buildActionWidgetRequest(pageId: string, actionId: string): WidgetPayload | null {
273
+ const packed = buildActionLaunchPayload(pageId, actionId)
274
+ if (!packed) return null
275
+ if (isDirectHostAction(packed.action)) return null
276
+ return {
277
+ workflow: packed.action.workflow || packed.action.skill || '',
278
+ title: packed.action.label,
279
+ host: packed.action.host,
280
+ prefilled: packed.prefilled,
281
+ hostContext: packed.hostContext,
282
+ }
283
+ }
284
+
285
+ export function getCurrentPageId() {
286
+ return currentPageId
287
+ }
288
+
289
+ export function getRegisteredPages(): { pageId: string; actions: HostPageAction[] }[] {
290
+ return Object.entries(pageActions).map(([pageId, actions]) => ({ pageId, actions }))
291
+ }
292
+
293
+ export function getActionAnchorElement(pageId: string, actionId: string): HTMLElement | null {
294
+ const action = (pageActions[pageId] || []).find((a) => a.id === actionId)
295
+ if (!action) return null
296
+ return resolveAnchorElement(action.anchor)
297
+ }
298
+
299
+ export function getActionAnchorRect(pageId: string, actionId: string): DOMRect | null {
300
+ const el = getActionAnchorElement(pageId, actionId)
301
+ if (!el) return null
302
+ const r = el.getBoundingClientRect()
303
+ if (!r.width && !r.height) return null
304
+ return r
305
+ }
306
+
307
+ export function getCurrentPagePrimaryAction(): PrimaryPageHint | null {
308
+ if (hintDismissed || !currentPageId) return null
309
+ const actions = pageActions[currentPageId] || []
310
+ for (const a of actions) {
311
+ if (a.enabled && !a.enabled()) continue
312
+ if (actionWantsAnchorHint(a) && anchorFocusedActionId !== a.id) continue
313
+ const hint = a.hint ? resolveMaybe(a.hint) : `可使用「${a.label}」`
314
+ const placement: HostHintPlacement = actionWantsAnchorHint(a) ? 'anchor' : 'launcher'
315
+ return { pageId: currentPageId, actionId: a.id, hint, placement }
316
+ }
317
+ return null
318
+ }
319
+
320
+ /** @deprecated 使用 getCurrentPagePrimaryAction */
321
+ export function getCurrentPageHintText(): string | null {
322
+ return getCurrentPagePrimaryAction()?.hint ?? null
323
+ }
324
+
325
+ export function resolveHostAnchorElement(ref: HostAnchorRef | (() => HostAnchorRef)): HTMLElement | null {
326
+ return resolveAnchorElement(ref)
327
+ }
package/src/types.ts ADDED
@@ -0,0 +1,159 @@
1
+ export type HostActionKind = 'widget' | 'direct'
2
+
3
+ export type HostHintPlacement = 'launcher' | 'anchor'
4
+
5
+ /**
6
+ * 横幅锚点。优先传真实 DOM(`HTMLElement`)。
7
+ * Vue 2 单根组件可传实例(用 `$el`)。Vue 3 多根 / React 组件实例没有稳定 `$el`,请 `querySelector` 或 ref 取到节点再传入。
8
+ */
9
+ export type HostAnchorRef =
10
+ | Element
11
+ | { $el?: Element | null }
12
+ | null
13
+ | undefined
14
+
15
+ export type HostPageAction = {
16
+ id: string
17
+ /** Widget 工作流名;kind=direct 时可省略,此时用 skill */
18
+ workflow?: string
19
+ label: string
20
+ host?: string
21
+ /**
22
+ * widget(默认):打开 FormWidget。
23
+ * direct:不弹窗。有 skill 时 Hub 发 Chat;仅有 workflow 时静默 POST /host/scenarios(不弹 Widget)。
24
+ */
25
+ kind?: HostActionKind
26
+ /** 内置 skill slug;kind=direct 时写入启动上下文 */
27
+ skill?: string
28
+ /**
29
+ * 可选覆盖。场景默认的 no_tools / skip_post_summary 在 Agent host_ui_registry。
30
+ * 不要写进 Skill,以免 Chat `@` 被连带改成无工具。
31
+ */
32
+ noTools?: boolean
33
+ hostContext?: Record<string, unknown> | (() => Record<string, unknown>)
34
+ prefilled?: Record<string, unknown> | (() => Record<string, unknown>)
35
+ enabled?: () => boolean
36
+ hint?: string | (() => string)
37
+ /**
38
+ * launcher(默认):横幅贴在右下角 FAB 上方。
39
+ * anchor:横幅贴在 `anchor` 控件旁;仅当该控件处于焦点时显示。
40
+ */
41
+ hintPlacement?: HostHintPlacement
42
+ /** 控件锚点;函数在 registerPage 时于宿主求值 */
43
+ anchor?: HostAnchorRef | (() => HostAnchorRef)
44
+ }
45
+
46
+ /** EDC Host prefilled 公共字段(snake_case 为主,兼容宿主 camelCase) */
47
+ export type HostPrefilledContext = {
48
+ sub_project_id?: string
49
+ subProjectId?: string
50
+ project_id?: string
51
+ projectId?: string
52
+ sort_start?: number
53
+ }
54
+
55
+ /** EDC AI 填表 prefilled */
56
+ export type HostPrefilledPatients = HostPrefilledContext & {
57
+ forms?: unknown[]
58
+ visits?: unknown[]
59
+ target?: {
60
+ form_name?: string
61
+ form_guid?: string
62
+ visit_name?: string
63
+ visit_guid?: string
64
+ period_name?: string
65
+ form_fields: { fields: unknown[]; formName?: string; formGuid?: string }
66
+ }
67
+ }
68
+
69
+ export type OpenWorkflowOptions = {
70
+ host?: string
71
+ hostContext?: Record<string, unknown>
72
+ prefilled?: Record<string, unknown>
73
+ title?: string
74
+ label?: string
75
+ sessionId?: string
76
+ preferSandboxFiles?: boolean
77
+ }
78
+
79
+ export type HostRefreshPayload = {
80
+ taskId?: string
81
+ result?: string
82
+ hostContext?: Record<string, unknown>
83
+ apply?: Record<string, unknown>
84
+ }
85
+
86
+ /** 宿主会话级上下文(抽屉提问注入;专病 / 我的项目 / 数据探索 / 数据分析共用 name + description) */
87
+ export type HostPageContext = {
88
+ /**
89
+ * 宿主系统标识,例如:
90
+ * - 专病数据库系统
91
+ * - 我的项目(EDC;属科研平台子系统)
92
+ * - 数据探索(属科研平台子系统)
93
+ * - 数据分析(在线分析;属科研平台子系统,路由 /analyzer)
94
+ * 科研平台本身包含「我的项目」「数据探索」「数据分析」等子系统,不要笼统写「科研平台」。
95
+ */
96
+ system?: string
97
+ /** 专病名或 EDC 项目名 */
98
+ name?: string
99
+ /** 专病描述或项目说明 */
100
+ description?: string
101
+ /** 当前菜单或页面名(按需读取时从路由取) */
102
+ menu?: string
103
+ [key: string]: unknown
104
+ }
105
+
106
+ /**
107
+ * registerPage 可选:任务完成刷新。
108
+ * 页面 unregister 后自动失效,无需宿主自己管 window 事件。
109
+ */
110
+ export type RegisterPageOptions = {
111
+ /** 与 hostContext.refresh 对齐,如 form_list / export_list */
112
+ refreshKey?: string
113
+ /** 当前页仍注册时,任务成功完成且 hostContext.refresh 匹配则调用 */
114
+ onRefresh?: (payload?: HostRefreshPayload) => void
115
+ }
116
+
117
+ /** 宿主 BFF bootstrap 响应(boot-admin GET /agent/bootstrap) */
118
+ export type BootstrapResult = {
119
+ agent_token?: string
120
+ refresh_token?: string
121
+ /** Agent 前端 origin,由 BFF 下发,SDK 用于 iframe 地址 */
122
+ agent_web_origin?: string
123
+ integrations?: Record<string, unknown>
124
+ cached?: boolean
125
+ error?: string
126
+ }
127
+
128
+ export type HostInitOptions = {
129
+ /**
130
+ * Agent 前端 origin(可选)。
131
+ * 优先使用 bootstrap 返回的 `agent_web_origin`;未配置且 bootstrap 未返回时报错。
132
+ */
133
+ agentOrigin?: string
134
+ host?: string
135
+ /** 宿主 BFF bootstrap;返回 token + agent_web_origin */
136
+ bootstrap?: () => Promise<BootstrapResult>
137
+ /**
138
+ * 打开 Widget 前钩子(如强制重新 bootstrap / establish)。
139
+ * Hub 可复用缓存 JWT;推沙盒等业务 API 需要 establish,应在此刷新。
140
+ */
141
+ prepareOpenWidget?: () => Promise<void>
142
+ /** 获取 Agent JWT;未提供时由 bootstrap 缓存 token */
143
+ getToken?: () => Promise<string>
144
+ /**
145
+ * 按需读取宿主上下文(系统 / 名称 / 描述 / 菜单)。
146
+ * Hub 场景弹窗打开时会 `request-host-context`;优先走此回调,无需路由全局监测。
147
+ */
148
+ getHostContext?: () => HostPageContext | Promise<HostPageContext>
149
+ showHub?: boolean
150
+ onBootstrap?: (result: BootstrapResult) => void
151
+ onTaskComplete?: (payload: {
152
+ taskId: string
153
+ status: string
154
+ hostContext?: Record<string, unknown>
155
+ result?: string
156
+ apply?: Record<string, unknown>
157
+ }) => void
158
+ onAuthRequired?: () => void
159
+ }