@tbox.cn/app-template-agent 0.7.5 → 0.9.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/.gitignore +5 -0
- package/.tbox/app.json +9 -1
- package/AGENTS.md +12 -4
- package/ENV.md +105 -21
- package/README.md +40 -38
- package/apps/client/VISUAL_RESTORE.md +11 -11
- package/apps/client/index.html +3 -1
- package/apps/client/package.json +9 -8
- package/apps/client/src/business/App.tsx +4 -4
- package/apps/client/src/business/README.md +5 -11
- package/apps/client/src/cards/registry.ts +23 -8
- package/apps/client/src/main.tsx +36 -2
- package/apps/client/src/modules.ts +7 -4
- package/apps/client/src/pages/index/index.tsx +18 -162
- package/apps/client/src/platform/AgentChatApp.tsx +7 -6
- package/apps/client/src/platform/agent-runtime.ts +5 -0
- package/apps/client/src/store/chat.ts +47 -185
- package/apps/client/src/test/setup.ts +7 -0
- package/apps/client/vite.config.ts +16 -1
- package/apps/server/package.json +0 -3
- package/apps/server/src/app-tools.ts +2 -1
- package/apps/server/src/app.ts +248 -119
- package/apps/server/src/config/integration.ts +2 -6
- package/apps/server/src/dev.ts +0 -1
- package/apps/server/src/intent-routes.ts +3 -3
- package/apps/server/src/modules.ts +4 -4
- package/apps/server/src/pre-tools.ts +4 -3
- package/apps/server/tests/agent-prompt.test.ts +81 -0
- package/apps/server/tests/cards-registry.test.ts +1 -3
- package/apps/server/tests/deployment-loader.test.ts +35 -56
- package/apps/server/tests/tool-runtime.test.ts +25 -61
- package/package.json +5 -4
- package/packages/agent-ui-kit/LICENSE +21 -0
- package/packages/agent-ui-kit/README.md +17 -0
- package/packages/agent-ui-kit/package.json +58 -0
- package/packages/agent-ui-kit/src/ChatPage.less +62 -0
- package/packages/agent-ui-kit/src/ChatPage.tsx +305 -0
- package/packages/agent-ui-kit/src/chat-config.test.ts +82 -0
- package/packages/agent-ui-kit/src/chat-config.ts +62 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/DebugButton.tsx +2 -2
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.less +19 -16
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.tsx +10 -15
- package/packages/agent-ui-kit/src/components/CardRenderer.tsx +36 -0
- package/packages/agent-ui-kit/src/components/ChatHeader/index.less +148 -0
- package/packages/agent-ui-kit/src/components/ChatHeader/index.test.tsx +74 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatHeader/index.tsx +27 -15
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.less +47 -39
- package/packages/agent-ui-kit/src/components/ChatInput/index.test.tsx +156 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.tsx +13 -4
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/useVoicePress.ts +2 -2
- package/packages/agent-ui-kit/src/components/HistoryEntry/index.less +49 -0
- package/packages/agent-ui-kit/src/components/HistoryEntry/index.tsx +42 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.less +3 -3
- package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.tsx +6 -6
- package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.less +25 -20
- package/packages/agent-ui-kit/src/components/MessageBubble/index.test.tsx +112 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.tsx +22 -17
- package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.less +2 -2
- package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.tsx +2 -2
- package/packages/agent-ui-kit/src/components/QuickCommands/index.less +63 -0
- package/packages/agent-ui-kit/src/components/QuickCommands/index.test.tsx +99 -0
- package/packages/agent-ui-kit/src/components/QuickCommands/index.tsx +52 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.less +1 -1
- package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.test.ts +346 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.ts +204 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.less +312 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.test.tsx +261 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.tsx +212 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/titles.ts +102 -0
- package/packages/agent-ui-kit/src/hooks/use-drag-gesture.test.ts +66 -0
- package/packages/agent-ui-kit/src/hooks/use-drag-gesture.ts +129 -0
- package/packages/agent-ui-kit/src/hooks/use-pull-down-history.ts +241 -0
- package/packages/agent-ui-kit/src/hooks/use-pull-down-wheel-native.test.ts +64 -0
- package/{apps/client → packages/agent-ui-kit}/src/hooks/useMediaUpload.ts +3 -3
- package/{apps/client → packages/agent-ui-kit}/src/hooks/useTTSAutoPlay.ts +7 -5
- package/packages/agent-ui-kit/src/index.ts +69 -0
- package/packages/agent-ui-kit/src/store-context.tsx +92 -0
- package/packages/agent-ui-kit/src/test/setup.ts +7 -0
- package/packages/agent-ui-kit/src/theme/contract.json +72 -0
- package/packages/agent-ui-kit/src/theme/global.less +99 -0
- package/packages/agent-ui-kit/src/theme/motion.less +19 -0
- package/packages/agent-ui-kit/src/theme/theme-contract.test.ts +69 -0
- package/packages/agent-ui-kit/src/theme/theme.ts +17 -0
- package/packages/agent-ui-kit/src/theme/tokens.less +79 -0
- package/packages/agent-ui-kit/src/utils/haptics.test.ts +51 -0
- package/packages/agent-ui-kit/src/utils/haptics.ts +44 -0
- package/packages/agent-ui-kit/src/utils/pull-down-machine.test.ts +188 -0
- package/packages/agent-ui-kit/src/utils/pull-down-machine.ts +157 -0
- package/packages/agent-ui-kit/src/utils/tool-call-display.test.ts +240 -0
- package/packages/agent-ui-kit/src/utils/tool-call-display.ts +146 -0
- package/packages/agent-ui-kit/src/vite-env.d.ts +1 -0
- package/packages/agent-ui-kit/tsconfig.json +8 -0
- package/packages/agent-ui-kit/vitest.config.ts +7 -0
- package/pnpm-lock.template.yaml +177 -111
- package/pnpm-workspace.template.yaml +12 -4
- package/profiles/mall/config/agent.jsonc +33 -0
- package/profiles/mall/config/chat.json +21 -0
- package/profiles/mall/config/knowledge.json +5 -0
- package/profiles/mall/config/llm_provider.json +5 -0
- package/profiles/mall/config/plugins.json +3 -0
- package/profiles/mall.json +16 -0
- package/scripts/check-workspace.mjs +3 -2
- package/apps/client/assets/copy@2x.png +0 -0
- package/apps/client/assets/debug.png +0 -0
- package/apps/client/assets/dislike@2x.png +0 -0
- package/apps/client/assets/like@2x.png +0 -0
- package/apps/client/assets/speaker@2x.png +0 -0
- package/apps/client/src/cards/CardRenderer.tsx +0 -20
- package/apps/client/src/components/ChatHeader/index.less +0 -104
- package/apps/client/src/components/ErrorBoundary.tsx +0 -2
- package/apps/client/src/components/QuickCommands/index.less +0 -48
- package/apps/client/src/components/QuickCommands/index.tsx +0 -29
- package/apps/client/src/components/ToolCallBlock/index.less +0 -131
- package/apps/client/src/components/ToolCallBlock/index.tsx +0 -125
- package/apps/client/src/examples/tourism/TourismLanding.tsx +0 -96
- package/apps/client/src/examples/tourism/index.less +0 -192
- package/apps/client/src/pages/index/index.less +0 -43
- package/apps/client/src/store/chat-config.ts +0 -40
- package/apps/client/src/styles/global.less +0 -60
- package/apps/client/src/styles/tokens.less +0 -60
- package/apps/server/src/config/deployment.ts +0 -47
- package/packages/module-weather/package.json +0 -36
- package/packages/module-weather/skills/weather-guide/SKILL.md +0 -22
- package/packages/module-weather/skills/weather-guide/references/dressing-tips.md +0 -5
- package/packages/module-weather/src/client/cards/weather/index.less +0 -22
- package/packages/module-weather/src/client/cards/weather/index.tsx +0 -14
- package/packages/module-weather/src/client/index.ts +0 -19
- package/packages/module-weather/src/index.ts +0 -2
- package/packages/module-weather/src/server/cards/weather/meta.ts +0 -27
- package/packages/module-weather/src/server/index.ts +0 -45
- package/packages/module-weather/src/server/pre-tools.ts +0 -20
- package/packages/module-weather/src/server/tool.ts +0 -29
- package/packages/module-weather/tbox.module.json +0 -27
- package/packages/module-weather/tsconfig.json +0 -14
- /package/{apps/client → packages/agent-ui-kit}/assets/keyboard-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/more-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/new-chat.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/quick-command-star.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/send-gradient.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/stop@2x.png +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/voice-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoicePressField.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoiceRecordingOverlay.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/machine.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.less +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.tsx +0 -0
- /package/{apps/client/src/types/chat.ts → packages/agent-ui-kit/src/types.ts} +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/copyText.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/imagePreview.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/session-error.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/toast.ts +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 容器级拖拽手势绑定(pointer 优先,touch 回退)。
|
|
3
|
+
*
|
|
4
|
+
* 两条必须绕开 React 合成事件的理由:
|
|
5
|
+
* 1. React 的 touchmove 注册在 root 上且是 passive,其中的 preventDefault() 必然失败——
|
|
6
|
+
* 桌面靠 overscroll-behavior 兜住,web-view(旧 WebKit)会被原生滚动吞掉手势。
|
|
7
|
+
* 2. 触摸设备上需要覆盖单指轻扫与单指拖拽,两者都只经 touch/pointer 通道。
|
|
8
|
+
*
|
|
9
|
+
* 因此统一在容器元素上绑 Pointer Events(非 passive),无 PointerEvent 的运行时回退 touch。
|
|
10
|
+
* 页面不再往 JSX 上绑手势事件,避免同一手势走两遍。
|
|
11
|
+
*
|
|
12
|
+
* **刻意不响应鼠标拖拽**(pointerType === 'mouse'):桌面按住鼠标拖动页面不是自然交互
|
|
13
|
+
* (那里的惯例是选中文本),桌面的下拉意图由触控板双指滑动表达、走 wheel 通道。
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useRef } from 'react';
|
|
16
|
+
|
|
17
|
+
export interface DragGestureHandlers {
|
|
18
|
+
onStart: (point: { clientY: number }) => void;
|
|
19
|
+
/** 返回 true 表示本次由手势接管,需阻止原生滚动。 */
|
|
20
|
+
onMove: (point: { clientY: number }) => boolean;
|
|
21
|
+
onEnd: () => void;
|
|
22
|
+
onCancel?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseDragGestureOptions extends DragGestureHandlers {
|
|
26
|
+
scrollRef?: React.RefObject<HTMLElement | null>;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useDragGesture({
|
|
31
|
+
scrollRef,
|
|
32
|
+
disabled,
|
|
33
|
+
onStart,
|
|
34
|
+
onMove,
|
|
35
|
+
onEnd,
|
|
36
|
+
onCancel,
|
|
37
|
+
}: UseDragGestureOptions): void {
|
|
38
|
+
// handler 存 ref:容器监听只在 disabled/元素变化时重绑,避免每次渲染 add/remove 抖动
|
|
39
|
+
const handlersRef = useRef<DragGestureHandlers>({ onStart, onMove, onEnd, onCancel });
|
|
40
|
+
handlersRef.current = { onStart, onMove, onEnd, onCancel };
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const el = scrollRef?.current;
|
|
44
|
+
if (!el || disabled) return undefined;
|
|
45
|
+
|
|
46
|
+
const usePointer = typeof window !== 'undefined' && 'PointerEvent' in window;
|
|
47
|
+
let dragging = false;
|
|
48
|
+
|
|
49
|
+
const preventIfTaken = (event: Event, taken: boolean) => {
|
|
50
|
+
if (taken && event.cancelable) event.preventDefault();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ── pointer 路径(mouse / touch / pen 统一)──
|
|
54
|
+
/** 鼠标不参与拖拽手势:桌面下拉意图走 wheel(触控板双指),见文件头说明。 */
|
|
55
|
+
const isDragPointer = (e: PointerEvent) => e.pointerType !== 'mouse';
|
|
56
|
+
|
|
57
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
58
|
+
if (!isDragPointer(e)) return;
|
|
59
|
+
dragging = true;
|
|
60
|
+
handlersRef.current.onStart({ clientY: e.clientY });
|
|
61
|
+
};
|
|
62
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
63
|
+
if (!dragging || !isDragPointer(e)) return;
|
|
64
|
+
preventIfTaken(e, handlersRef.current.onMove({ clientY: e.clientY }));
|
|
65
|
+
};
|
|
66
|
+
const onPointerUp = () => {
|
|
67
|
+
if (!dragging) return;
|
|
68
|
+
dragging = false;
|
|
69
|
+
handlersRef.current.onEnd();
|
|
70
|
+
};
|
|
71
|
+
const onPointerCancel = () => {
|
|
72
|
+
if (!dragging) return;
|
|
73
|
+
dragging = false;
|
|
74
|
+
(handlersRef.current.onCancel ?? handlersRef.current.onEnd)();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── touch 回退(无 PointerEvent 的旧 WebView)──
|
|
78
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
79
|
+
const touch = e.touches[0];
|
|
80
|
+
if (!touch) return;
|
|
81
|
+
dragging = true;
|
|
82
|
+
handlersRef.current.onStart({ clientY: touch.clientY });
|
|
83
|
+
};
|
|
84
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
85
|
+
const touch = e.touches[0];
|
|
86
|
+
if (!touch || !dragging) return;
|
|
87
|
+
preventIfTaken(e, handlersRef.current.onMove({ clientY: touch.clientY }));
|
|
88
|
+
};
|
|
89
|
+
const onTouchEnd = () => {
|
|
90
|
+
if (!dragging) return;
|
|
91
|
+
dragging = false;
|
|
92
|
+
handlersRef.current.onEnd();
|
|
93
|
+
};
|
|
94
|
+
const onTouchCancel = () => {
|
|
95
|
+
if (!dragging) return;
|
|
96
|
+
dragging = false;
|
|
97
|
+
(handlersRef.current.onCancel ?? handlersRef.current.onEnd)();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (usePointer) {
|
|
101
|
+
el.addEventListener('pointerdown', onPointerDown, { passive: true });
|
|
102
|
+
el.addEventListener('pointermove', onPointerMove, { passive: false });
|
|
103
|
+
el.addEventListener('pointerup', onPointerUp, { passive: true });
|
|
104
|
+
el.addEventListener('pointercancel', onPointerCancel, { passive: true });
|
|
105
|
+
// 指针移出容器(鼠标拖到窗口外松开)同样收口,否则位移悬挂
|
|
106
|
+
el.addEventListener('pointerleave', onPointerUp, { passive: true });
|
|
107
|
+
} else {
|
|
108
|
+
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
109
|
+
el.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
110
|
+
el.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
111
|
+
el.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
if (usePointer) {
|
|
116
|
+
el.removeEventListener('pointerdown', onPointerDown);
|
|
117
|
+
el.removeEventListener('pointermove', onPointerMove);
|
|
118
|
+
el.removeEventListener('pointerup', onPointerUp);
|
|
119
|
+
el.removeEventListener('pointercancel', onPointerCancel);
|
|
120
|
+
el.removeEventListener('pointerleave', onPointerUp);
|
|
121
|
+
} else {
|
|
122
|
+
el.removeEventListener('touchstart', onTouchStart);
|
|
123
|
+
el.removeEventListener('touchmove', onTouchMove);
|
|
124
|
+
el.removeEventListener('touchend', onTouchEnd);
|
|
125
|
+
el.removeEventListener('touchcancel', onTouchCancel);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}, [disabled, scrollRef]);
|
|
129
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 下拉加载历史 React 薄包装(机器见 pull-down-machine.ts)。
|
|
3
|
+
* wrapper 职责:machine 存 ref、状态镜像 useState、noMore 定时 reset、取消手势 reset、
|
|
4
|
+
* hasMore false→true 跃迁 reset(G9:分页游标重置后手势死区复活)及异步加载 in-flight 门控。
|
|
5
|
+
* 滚动位置真源在页面(isAtTopRef 只读透传);store 的 isLoadingHistory 仍是跨调用业务防抖权威。
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
|
+
import { createPullDownMachine, NO_MORE_HOLD_MS, WHEEL_IDLE_RESET_MS } from '../utils/pull-down-machine';
|
|
9
|
+
import { useDragGesture } from './use-drag-gesture';
|
|
10
|
+
import { hapticTap } from '../utils/haptics';
|
|
11
|
+
import type { PullStatus } from '../utils/pull-down-machine';
|
|
12
|
+
|
|
13
|
+
export interface UsePullDownHistoryOptions {
|
|
14
|
+
isAtTopRef: React.MutableRefObject<boolean>;
|
|
15
|
+
isLoadingHistory: boolean;
|
|
16
|
+
hasMore: boolean;
|
|
17
|
+
onLoad: () => Promise<void>;
|
|
18
|
+
/** 鉴权或运行时尚未就绪时禁用手势,避免发出归属不明的历史请求。 */
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* 滚动容器 ref。传入后整套手势由容器上的原生监听接管(pointer 优先、touch 回退、非 passive):
|
|
22
|
+
* React 合成事件是 passive 阻止不了原生滚动,且只监听 touch 会漏掉 pointerType=mouse
|
|
23
|
+
* 的输入(开发者工具模拟器与桌面浏览器)。传了就**不要**再往 JSX 绑手势事件,否则走两遍。
|
|
24
|
+
*/
|
|
25
|
+
scrollRef?: React.RefObject<HTMLElement | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function usePullDownHistory(opts: UsePullDownHistoryOptions): {
|
|
29
|
+
pullStatus: PullStatus;
|
|
30
|
+
pullDistance: number;
|
|
31
|
+
handleTouchStart: (e: React.TouchEvent) => void;
|
|
32
|
+
handleTouchMove: (e: React.TouchEvent) => void;
|
|
33
|
+
handleTouchEnd: () => void;
|
|
34
|
+
handleTouchCancel: () => void;
|
|
35
|
+
handleWheel: (e: React.WheelEvent) => void;
|
|
36
|
+
} {
|
|
37
|
+
const { isAtTopRef, isLoadingHistory, hasMore, onLoad, disabled, scrollRef } = opts;
|
|
38
|
+
const machineRef = useRef(createPullDownMachine());
|
|
39
|
+
const [pullStatus, setPullStatus] = useState<PullStatus>('idle');
|
|
40
|
+
const [pullDistance, setPullDistance] = useState(0);
|
|
41
|
+
const noMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
42
|
+
const loadInFlightRef = useRef<Promise<void> | null>(null);
|
|
43
|
+
const wheelIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
/** 本次连续滚动是否已触发过加载——惯性未停前不允许第二次。 */
|
|
45
|
+
const wheelLockedRef = useRef(false);
|
|
46
|
+
|
|
47
|
+
// 跨过触发线那一刻给一次轻触反馈:手势语义从「还在拉」变成「松手即加载」,
|
|
48
|
+
// 值得一个非视觉的确认信号(fail-soft,平台不支持即静默)。
|
|
49
|
+
const prevStatusRef = useRef<PullStatus>('idle');
|
|
50
|
+
|
|
51
|
+
const sync = useCallback(() => {
|
|
52
|
+
const { status, distance } = machineRef.current.getState();
|
|
53
|
+
if (status === 'ready' && prevStatusRef.current !== 'ready') hapticTap();
|
|
54
|
+
prevStatusRef.current = status;
|
|
55
|
+
setPullStatus(status);
|
|
56
|
+
setPullDistance(distance);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const triggerLoad = useCallback(() => {
|
|
60
|
+
if (loadInFlightRef.current) return;
|
|
61
|
+
const task: Promise<void> = onLoad()
|
|
62
|
+
.catch(() => undefined)
|
|
63
|
+
.finally(() => {
|
|
64
|
+
if (loadInFlightRef.current === task) loadInFlightRef.current = null;
|
|
65
|
+
machineRef.current.reset();
|
|
66
|
+
sync();
|
|
67
|
+
});
|
|
68
|
+
loadInFlightRef.current = task;
|
|
69
|
+
}, [onLoad, sync]);
|
|
70
|
+
|
|
71
|
+
const handleTouchStart = useCallback(
|
|
72
|
+
(e: React.TouchEvent) => {
|
|
73
|
+
if (disabled) return;
|
|
74
|
+
const touch = e.touches[0];
|
|
75
|
+
if (!touch) return;
|
|
76
|
+
machineRef.current.touchStart(touch.clientY);
|
|
77
|
+
},
|
|
78
|
+
[disabled],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/** 推进机器并返回「是否需要阻止原生滚动」——合成事件与原生监听共用同一条逻辑。 */
|
|
82
|
+
const advanceTouchMove = useCallback(
|
|
83
|
+
(touch: { clientY: number }): boolean => {
|
|
84
|
+
if (disabled) return false;
|
|
85
|
+
const { preventDefault } = machineRef.current.touchMove({
|
|
86
|
+
clientY: touch.clientY,
|
|
87
|
+
isAtTop: isAtTopRef.current,
|
|
88
|
+
hasMore,
|
|
89
|
+
isLoading: isLoadingHistory,
|
|
90
|
+
});
|
|
91
|
+
sync();
|
|
92
|
+
return preventDefault;
|
|
93
|
+
},
|
|
94
|
+
[disabled, hasMore, isLoadingHistory, isAtTopRef, sync],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// 容器 ref 在场时由原生监听接管整套手势(pointer 优先,覆盖 mouse/touch/pen)
|
|
98
|
+
useDragGesture({
|
|
99
|
+
scrollRef,
|
|
100
|
+
disabled,
|
|
101
|
+
onStart: (point) => machineRef.current.touchStart(point.clientY),
|
|
102
|
+
onMove: advanceTouchMove,
|
|
103
|
+
onEnd: () => {
|
|
104
|
+
const { shouldLoad, holdNoMore } = machineRef.current.touchEnd({ hasMore });
|
|
105
|
+
if (shouldLoad) {
|
|
106
|
+
triggerLoad();
|
|
107
|
+
} else if (holdNoMore) {
|
|
108
|
+
if (noMoreTimerRef.current) clearTimeout(noMoreTimerRef.current);
|
|
109
|
+
noMoreTimerRef.current = setTimeout(() => {
|
|
110
|
+
machineRef.current.reset();
|
|
111
|
+
sync();
|
|
112
|
+
}, NO_MORE_HOLD_MS);
|
|
113
|
+
}
|
|
114
|
+
sync();
|
|
115
|
+
},
|
|
116
|
+
onCancel: () => {
|
|
117
|
+
machineRef.current.reset();
|
|
118
|
+
sync();
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const handleTouchMove = useCallback(
|
|
123
|
+
(e: React.TouchEvent) => {
|
|
124
|
+
const touch = e.touches[0];
|
|
125
|
+
if (!touch) return;
|
|
126
|
+
if (advanceTouchMove(touch)) {
|
|
127
|
+
try {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
} catch {
|
|
130
|
+
/* React 合成事件为 passive:此路径下阻止不了,容器 ref 形态才是权威 */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[advanceTouchMove],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const handleTouchEnd = useCallback(() => {
|
|
138
|
+
if (disabled) return;
|
|
139
|
+
const { shouldLoad, holdNoMore } = machineRef.current.touchEnd({ hasMore });
|
|
140
|
+
if (shouldLoad) {
|
|
141
|
+
triggerLoad();
|
|
142
|
+
} else if (holdNoMore) {
|
|
143
|
+
if (noMoreTimerRef.current) clearTimeout(noMoreTimerRef.current);
|
|
144
|
+
noMoreTimerRef.current = setTimeout(() => {
|
|
145
|
+
machineRef.current.reset();
|
|
146
|
+
sync();
|
|
147
|
+
}, NO_MORE_HOLD_MS);
|
|
148
|
+
}
|
|
149
|
+
sync();
|
|
150
|
+
}, [disabled, hasMore, sync, triggerLoad]);
|
|
151
|
+
|
|
152
|
+
const handleTouchCancel = useCallback(() => {
|
|
153
|
+
machineRef.current.reset();
|
|
154
|
+
sync();
|
|
155
|
+
}, [sync]);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* wheel 没有「松手」事件,用「静默 WHEEL_IDLE_RESET_MS」代替手势结束:
|
|
159
|
+
* 到期才复位视觉并解锁。惯性期间每个事件都会重设它,因此锁一直有效。
|
|
160
|
+
*/
|
|
161
|
+
const scheduleWheelIdle = useCallback(() => {
|
|
162
|
+
if (wheelIdleTimerRef.current) clearTimeout(wheelIdleTimerRef.current);
|
|
163
|
+
wheelIdleTimerRef.current = setTimeout(() => {
|
|
164
|
+
machineRef.current.wheelIdle();
|
|
165
|
+
wheelIdleTimerRef.current = null;
|
|
166
|
+
wheelLockedRef.current = false;
|
|
167
|
+
sync();
|
|
168
|
+
}, WHEEL_IDLE_RESET_MS);
|
|
169
|
+
}, [sync]);
|
|
170
|
+
|
|
171
|
+
/** 推进 wheel 并返回「是否需要吞掉本次滚动」。原生监听与合成事件共用。 */
|
|
172
|
+
const advanceWheel = useCallback(
|
|
173
|
+
(deltaY: number): boolean => {
|
|
174
|
+
if (disabled) return false;
|
|
175
|
+
// 触发后到滚动停止之间:吞掉惯性。加载会把 scrollTop 抬到前插位置,
|
|
176
|
+
// 上方随即出现可滚空间,残余惯性会继续把它带走——落位就白设了。
|
|
177
|
+
if (wheelLockedRef.current) {
|
|
178
|
+
scheduleWheelIdle();
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const { shouldLoad } = machineRef.current.wheel({
|
|
182
|
+
deltaY,
|
|
183
|
+
isAtTop: isAtTopRef.current,
|
|
184
|
+
hasMore,
|
|
185
|
+
isLoading: isLoadingHistory,
|
|
186
|
+
});
|
|
187
|
+
sync();
|
|
188
|
+
if (shouldLoad) {
|
|
189
|
+
wheelLockedRef.current = true;
|
|
190
|
+
triggerLoad();
|
|
191
|
+
scheduleWheelIdle();
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
scheduleWheelIdle();
|
|
195
|
+
return false;
|
|
196
|
+
},
|
|
197
|
+
[disabled, hasMore, isLoadingHistory, isAtTopRef, scheduleWheelIdle, sync, triggerLoad],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// 原生非 passive 绑定:React 的 onWheel 同样是 passive,preventDefault 无效,
|
|
201
|
+
// 吞不掉惯性。与 touch 通道同理(见 use-drag-gesture 文件头)。
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const el = scrollRef?.current;
|
|
204
|
+
if (!el || disabled) return undefined;
|
|
205
|
+
const onWheel = (e: WheelEvent) => {
|
|
206
|
+
if (advanceWheel(e.deltaY) && e.cancelable) e.preventDefault();
|
|
207
|
+
};
|
|
208
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
209
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
210
|
+
}, [advanceWheel, disabled, scrollRef]);
|
|
211
|
+
|
|
212
|
+
const handleWheel = useCallback(
|
|
213
|
+
(e: React.WheelEvent) => {
|
|
214
|
+
if (disabled) return;
|
|
215
|
+
// 传了 scrollRef 时手势已由原生监听接管,此路径仅为不传 ref 的旧用法保留
|
|
216
|
+
if (scrollRef?.current) return;
|
|
217
|
+
advanceWheel(e.deltaY);
|
|
218
|
+
},
|
|
219
|
+
[advanceWheel, disabled, scrollRef],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// G9:hasMore false→true 跃迁(分页游标重置)→ 机器复位(noMore 残留死区复活)
|
|
223
|
+
const prevHasMoreRef = useRef(hasMore);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (prevHasMoreRef.current === false && hasMore) {
|
|
226
|
+
machineRef.current.reset();
|
|
227
|
+
sync();
|
|
228
|
+
}
|
|
229
|
+
prevHasMoreRef.current = hasMore;
|
|
230
|
+
}, [hasMore, sync]);
|
|
231
|
+
|
|
232
|
+
// unmount 清理 noMore 定时器
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
return () => {
|
|
235
|
+
if (noMoreTimerRef.current) clearTimeout(noMoreTimerRef.current);
|
|
236
|
+
if (wheelIdleTimerRef.current) clearTimeout(wheelIdleTimerRef.current);
|
|
237
|
+
};
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
return { pullStatus, pullDistance, handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel, handleWheel };
|
|
241
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* wheel 原生通道用例。核心契约:触发加载后到滚动停止之间,惯性 wheel 必须被 preventDefault
|
|
4
|
+
* 吞掉——加载会把 scrollTop 抬到前插位置,上方随即出现可滚空间,残余惯性会把落位带走。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { createRoot } from 'react-dom/client';
|
|
8
|
+
import { flushSync } from 'react-dom';
|
|
9
|
+
import { createElement, useRef } from 'react';
|
|
10
|
+
import { usePullDownHistory } from './use-pull-down-history';
|
|
11
|
+
import { WHEEL_TRIGGER_ACCUM } from '../utils/pull-down-machine';
|
|
12
|
+
|
|
13
|
+
function wheelEvent(deltaY: number) {
|
|
14
|
+
const e = new Event('wheel', { bubbles: true, cancelable: true }) as WheelEvent;
|
|
15
|
+
Object.defineProperty(e, 'deltaY', { value: deltaY });
|
|
16
|
+
return e;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function setup(onLoad: () => Promise<void>) {
|
|
20
|
+
const el = document.createElement('div');
|
|
21
|
+
document.body.appendChild(el);
|
|
22
|
+
const Host = () => {
|
|
23
|
+
const isAtTopRef = useRef(true);
|
|
24
|
+
usePullDownHistory({
|
|
25
|
+
isAtTopRef,
|
|
26
|
+
isLoadingHistory: false,
|
|
27
|
+
hasMore: true,
|
|
28
|
+
onLoad,
|
|
29
|
+
scrollRef: { current: el },
|
|
30
|
+
});
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
const host = document.createElement('div');
|
|
34
|
+
document.body.appendChild(host);
|
|
35
|
+
flushSync(() => createRoot(host).render(createElement(Host)));
|
|
36
|
+
return el;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('usePullDownHistory:wheel 原生通道', () => {
|
|
40
|
+
it('N1 未触发时不吞滚动(页面照常滚)', () => {
|
|
41
|
+
const el = setup(async () => {});
|
|
42
|
+
const e = wheelEvent(-10);
|
|
43
|
+
el.dispatchEvent(e);
|
|
44
|
+
expect(e.defaultPrevented).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('N2 越阈触发后吞掉后续惯性,落位不被带走', async () => {
|
|
48
|
+
const onLoad = vi.fn(async () => {});
|
|
49
|
+
const el = setup(onLoad);
|
|
50
|
+
|
|
51
|
+
const trigger = wheelEvent(-(WHEEL_TRIGGER_ACCUM + 20));
|
|
52
|
+
el.dispatchEvent(trigger);
|
|
53
|
+
expect(onLoad).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(trigger.defaultPrevented).toBe(true);
|
|
55
|
+
|
|
56
|
+
// 惯性尾巴:既不得二次触发,也不得让原生滚动继续
|
|
57
|
+
for (let i = 0; i < 5; i += 1) {
|
|
58
|
+
const inertia = wheelEvent(-80);
|
|
59
|
+
el.dispatchEvent(inertia);
|
|
60
|
+
expect(inertia.defaultPrevented).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
expect(onLoad).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -5,9 +5,9 @@ const mediaLogger = createLogger('media');
|
|
|
5
5
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
6
6
|
import { useUploadAdapter, useRefreshSession, chooseImage, chooseVideo } from '@tbox.cn/app-sdk/client';
|
|
7
7
|
|
|
8
|
-
import { isSessionExpiredError } from '
|
|
9
|
-
import { toast } from '
|
|
10
|
-
import type { MediaItem, MediaType, MediaConfig } from '
|
|
8
|
+
import { isSessionExpiredError } from '../utils/session-error';
|
|
9
|
+
import { toast } from '../utils/toast';
|
|
10
|
+
import type { MediaItem, MediaType, MediaConfig } from '../types';
|
|
11
11
|
|
|
12
12
|
let seq = 0;
|
|
13
13
|
const nid = () => `media_${Date.now()}_${seq++}`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import type { CoreChatState } from '@tbox.cn/app-sdk/client';
|
|
3
|
+
import { useChatStoreApi } from '../store-context';
|
|
3
4
|
import { useTTSAdapter } from '@tbox.cn/app-sdk/client';
|
|
4
5
|
|
|
5
6
|
const FLUSH_MS = 300; // 累积 delta 后批量送,避免过多 WS 消息
|
|
@@ -10,6 +11,7 @@ const FLUSH_MS = 300; // 累积 delta 后批量送,避免过多 WS 消息
|
|
|
10
11
|
*/
|
|
11
12
|
export function useTTSAutoPlay(enabled: boolean) {
|
|
12
13
|
const tts = useTTSAdapter();
|
|
14
|
+
const store = useChatStoreApi();
|
|
13
15
|
|
|
14
16
|
useEffect(() => {
|
|
15
17
|
if (!enabled || !tts || !tts.startStreaming) return;
|
|
@@ -37,7 +39,7 @@ export function useTTSAutoPlay(enabled: boolean) {
|
|
|
37
39
|
if (!timer) timer = setTimeout(flush, FLUSH_MS);
|
|
38
40
|
};
|
|
39
41
|
|
|
40
|
-
const handle = (state:
|
|
42
|
+
const handle = (state: CoreChatState) => {
|
|
41
43
|
const { messages, streamingMsgId } = state;
|
|
42
44
|
const msg = messages.find((m) => m.id === streamingMsgId);
|
|
43
45
|
|
|
@@ -68,13 +70,13 @@ export function useTTSAutoPlay(enabled: boolean) {
|
|
|
68
70
|
}
|
|
69
71
|
};
|
|
70
72
|
|
|
71
|
-
const unsub =
|
|
72
|
-
handle(
|
|
73
|
+
const unsub = store.subscribe(handle);
|
|
74
|
+
handle(store.getState());
|
|
73
75
|
|
|
74
76
|
return () => {
|
|
75
77
|
unsub();
|
|
76
78
|
clearTimer();
|
|
77
79
|
if (activeId) tts.stop();
|
|
78
80
|
};
|
|
79
|
-
}, [enabled, tts]);
|
|
81
|
+
}, [enabled, tts, store]);
|
|
80
82
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @app/agent-ui-kit:Agent 对话壳 UI 组件包(源码发布,单一模板统一化 W2-1)。
|
|
3
|
+
*
|
|
4
|
+
* 消费面:应用经 `<ChatPage store cards slots .../>` 单点装配(或自组 KitChatProvider 树);
|
|
5
|
+
* 域模块(如 mall-home)经 home 槽渲染函数自持域 UI——kit 组件零域语义。
|
|
6
|
+
* 主题:`@app/agent-ui-kit/theme`(applyTheme)+ `theme/index.less`(配合纯 CSS 主题包级联)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── 首屏页面与注入位 ──
|
|
10
|
+
export { ChatPage } from './ChatPage';
|
|
11
|
+
export type { ChatPageProps } from './ChatPage';
|
|
12
|
+
// ── 行为面(W2-3 收口:SDK client 导出退役后宿主——下拉加载历史手势族)──
|
|
13
|
+
export { usePullDownHistory } from './hooks/use-pull-down-history';
|
|
14
|
+
export type { UsePullDownHistoryOptions } from './hooks/use-pull-down-history';
|
|
15
|
+
export {
|
|
16
|
+
KitChatProvider,
|
|
17
|
+
useChatStore,
|
|
18
|
+
useChatStoreApi,
|
|
19
|
+
useChatCards,
|
|
20
|
+
useChatRawCards,
|
|
21
|
+
useClientSlots,
|
|
22
|
+
} from './store-context';
|
|
23
|
+
export type { KitChatContextValue } from './store-context';
|
|
24
|
+
|
|
25
|
+
// ── chat 设置(构建期快照 + HELLO_ACK 水合) ──
|
|
26
|
+
export { configureChatConfigDefaults, useChatConfigStore } from './chat-config';
|
|
27
|
+
export type { QuickCommand } from './chat-config';
|
|
28
|
+
|
|
29
|
+
// ── 主题 ──
|
|
30
|
+
export { applyTheme, currentTheme } from './theme/theme';
|
|
31
|
+
|
|
32
|
+
// ── 卡片渲染(context 注册表注入) ──
|
|
33
|
+
export { CardRenderer, RawCardRenderer } from './components/CardRenderer';
|
|
34
|
+
|
|
35
|
+
// ── 壳组件族 ──
|
|
36
|
+
export { MessageBubble } from './components/MessageBubble';
|
|
37
|
+
export { ChatInput } from './components/ChatInput';
|
|
38
|
+
export { QuickCommands } from './components/QuickCommands';
|
|
39
|
+
export { MediaList } from './components/MediaList';
|
|
40
|
+
export { ActionBar, CopyButton, getMessageCopyText } from './components/ActionBar';
|
|
41
|
+
export { DebugButton } from './components/ActionBar/DebugButton';
|
|
42
|
+
export { default as ChatHeader } from './components/ChatHeader';
|
|
43
|
+
export { default as HistoryEntry } from './components/HistoryEntry';
|
|
44
|
+
export { default as PullDownIndicator } from './components/PullDownIndicator';
|
|
45
|
+
export { default as ReasoningBlock } from './components/ReasoningBlock';
|
|
46
|
+
export { default as ScrollToBottomButton } from './components/ScrollToBottomButton';
|
|
47
|
+
export { default as ToolCallBlock } from './components/ToolCallBlock';
|
|
48
|
+
|
|
49
|
+
// ── hooks / utils ──
|
|
50
|
+
export { useTTSAutoPlay } from './hooks/useTTSAutoPlay';
|
|
51
|
+
export { useMediaUpload } from './hooks/useMediaUpload';
|
|
52
|
+
export { toast } from './utils/toast';
|
|
53
|
+
export type { ToastTone } from './utils/toast';
|
|
54
|
+
export { copyText } from './utils/copyText';
|
|
55
|
+
export { previewImages } from './utils/imagePreview';
|
|
56
|
+
|
|
57
|
+
// ── 类型(协议类型 re-export 单一来源 + UI 层约定) ──
|
|
58
|
+
export type {
|
|
59
|
+
ChatInputMessage,
|
|
60
|
+
ChatMessage,
|
|
61
|
+
FileTypeConfig,
|
|
62
|
+
InputMethod,
|
|
63
|
+
MediaConfig,
|
|
64
|
+
MediaItem,
|
|
65
|
+
MediaType,
|
|
66
|
+
MessageRole,
|
|
67
|
+
MessageStatus,
|
|
68
|
+
ToolCallInfo,
|
|
69
|
+
} from './types';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KitChatProvider:壳组件的 store 注入位(单一模板统一化 W2-1)。
|
|
3
|
+
*
|
|
4
|
+
* 壳组件不 import 应用 store——经 React context 读取注入的 store 与卡片注册表;
|
|
5
|
+
* 应用在 `<ChatPage store={useChatStore} cards={CHAT_REGISTRY} rawCards={CARD_REGISTRY}/>`
|
|
6
|
+
* 一处装配(或自组 KitChatProvider 树)。
|
|
7
|
+
*
|
|
8
|
+
* useChatStore 调用签名与 zustand 绑定 hook 逐字兼容(`useChatStore((s)=>s.x)`)——
|
|
9
|
+
* 组件代码从应用 store 迁移零改写(仅 import 来源变化)。
|
|
10
|
+
*/
|
|
11
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
12
|
+
import type { CardComponent, TboxCardPayload } from '@tbox.cn/app-contracts';
|
|
13
|
+
import type { ClientSlotRegistry, CoreChatState, CoreChatStore } from '@tbox.cn/app-sdk/client';
|
|
14
|
+
|
|
15
|
+
export interface KitChatContextValue {
|
|
16
|
+
/** 应用注入的 chat store(zustand 绑定 hook + API) */
|
|
17
|
+
readonly store: CoreChatStore;
|
|
18
|
+
/** 聊天入口卡片注册表(模块注册 + 组合层覆盖) */
|
|
19
|
+
readonly cards: Record<string, CardComponent>;
|
|
20
|
+
/** 原始形态卡片注册表(无覆盖;详情弹层等防嵌套消费) */
|
|
21
|
+
readonly rawCards: Record<string, CardComponent>;
|
|
22
|
+
/** 客户端槽注册表(home 首期唯一槽;缺席 = chat 流形态) */
|
|
23
|
+
readonly slots?: ClientSlotRegistry;
|
|
24
|
+
/**
|
|
25
|
+
* 旁路/消息卡内联流过滤(域注入;缺省恒等):域声明哪些卡不进内联流
|
|
26
|
+
* (由域浮层自持,如 mall 会员门禁卡系)——同时参与 home-first 形态判定。
|
|
27
|
+
*/
|
|
28
|
+
readonly filterInlineCards?: (cards: readonly TboxCardPayload[]) => TboxCardPayload[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const IDENTITY_FILTER = (cards: readonly TboxCardPayload[]): TboxCardPayload[] => [...cards];
|
|
32
|
+
|
|
33
|
+
const KitChatContext = createContext<KitChatContextValue | null>(null);
|
|
34
|
+
|
|
35
|
+
export function KitChatProvider({
|
|
36
|
+
store,
|
|
37
|
+
cards,
|
|
38
|
+
rawCards,
|
|
39
|
+
slots,
|
|
40
|
+
filterInlineCards,
|
|
41
|
+
children,
|
|
42
|
+
}: KitChatContextValue & { children: ReactNode }) {
|
|
43
|
+
const value = useMemo<KitChatContextValue>(
|
|
44
|
+
() => ({
|
|
45
|
+
store,
|
|
46
|
+
cards,
|
|
47
|
+
rawCards,
|
|
48
|
+
...(slots ? { slots } : {}),
|
|
49
|
+
...(filterInlineCards ? { filterInlineCards } : {}),
|
|
50
|
+
}),
|
|
51
|
+
[store, cards, rawCards, slots, filterInlineCards],
|
|
52
|
+
);
|
|
53
|
+
return <KitChatContext.Provider value={value}>{children}</KitChatContext.Provider>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function useKitChatContext(): KitChatContextValue {
|
|
57
|
+
const value = useContext(KitChatContext);
|
|
58
|
+
if (!value) {
|
|
59
|
+
throw new Error('KitChatProvider 缺席:kit 组件须在 <ChatPage>(或自组 Provider)内使用');
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 选择器读取(镜像 zustand 绑定 hook 调用签名;组件从 context 取 store)。 */
|
|
65
|
+
export function useChatStore<T>(selector: (state: CoreChatState) => T): T {
|
|
66
|
+
return useKitChatContext().store(selector);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** store API(getState/subscribe;effect/事件回调场景)。 */
|
|
70
|
+
export function useChatStoreApi(): CoreChatStore {
|
|
71
|
+
return useKitChatContext().store;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 聊天入口卡片注册表(MessageBubble 等卡渲染消费)。 */
|
|
75
|
+
export function useChatCards(): Record<string, CardComponent> {
|
|
76
|
+
return useKitChatContext().cards;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 原始形态卡片注册表(RawCardRenderer 消费)。 */
|
|
80
|
+
export function useChatRawCards(): Record<string, CardComponent> {
|
|
81
|
+
return useKitChatContext().rawCards;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 客户端槽注册表(home 槽;缺席 = chat 流形态)。 */
|
|
85
|
+
export function useClientSlots(): ClientSlotRegistry | undefined {
|
|
86
|
+
return useKitChatContext().slots;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 旁路/消息卡内联流过滤器(域注入;缺省恒等)。 */
|
|
90
|
+
export function useInlineCardFilter(): (cards: readonly TboxCardPayload[]) => TboxCardPayload[] {
|
|
91
|
+
return useKitChatContext().filterInlineCards ?? IDENTITY_FILTER;
|
|
92
|
+
}
|