@tbox.cn/app-template-agent 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/.gitignore +13 -0
- package/.nvmrc +1 -0
- package/.tbox/app.json +4 -0
- package/AGENTS.md +101 -0
- package/ENV.md +77 -0
- package/LEGAL.md +7 -0
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/apps/client/STYLE_GUIDE.md +81 -0
- package/apps/client/VISUAL_RESTORE.md +90 -0
- 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/keyboard-circle.svg +12 -0
- package/apps/client/assets/like@2x.png +0 -0
- package/apps/client/assets/more-circle.svg +3 -0
- package/apps/client/assets/new-chat.svg +3 -0
- package/apps/client/assets/quick-command-star.svg +3 -0
- package/apps/client/assets/send-gradient.svg +14 -0
- package/apps/client/assets/speaker@2x.png +0 -0
- package/apps/client/assets/stop@2x.png +0 -0
- package/apps/client/assets/voice-circle.svg +3 -0
- package/apps/client/babel-plugins/code-inspect.cjs +110 -0
- package/apps/client/index.html +36 -0
- package/apps/client/package.json +34 -0
- package/apps/client/src/app.tsx +5 -0
- package/apps/client/src/business/App.tsx +11 -0
- package/apps/client/src/business/README.md +82 -0
- package/apps/client/src/cards/CardRenderer.tsx +10 -0
- package/apps/client/src/cards/registry.ts +22 -0
- package/apps/client/src/components/ActionBar/DebugButton.tsx +49 -0
- package/apps/client/src/components/ActionBar/index.less +120 -0
- package/apps/client/src/components/ActionBar/index.tsx +157 -0
- package/apps/client/src/components/ChatHeader/index.less +104 -0
- package/apps/client/src/components/ChatHeader/index.tsx +63 -0
- package/apps/client/src/components/ChatInput/index.less +332 -0
- package/apps/client/src/components/ChatInput/index.tsx +251 -0
- package/apps/client/src/components/ChatInput/voice/VoicePressField.tsx +34 -0
- package/apps/client/src/components/ChatInput/voice/VoiceRecordingOverlay.tsx +51 -0
- package/apps/client/src/components/ChatInput/voice/machine.ts +27 -0
- package/apps/client/src/components/ChatInput/voice/useVoicePress.ts +188 -0
- package/apps/client/src/components/ErrorBoundary.tsx +2 -0
- package/apps/client/src/components/MediaList/index.less +241 -0
- package/apps/client/src/components/MediaList/index.tsx +256 -0
- package/apps/client/src/components/MessageBubble/index.less +309 -0
- package/apps/client/src/components/MessageBubble/index.tsx +163 -0
- package/apps/client/src/components/PullDownIndicator/index.less +32 -0
- package/apps/client/src/components/PullDownIndicator/index.tsx +33 -0
- package/apps/client/src/components/QuickCommands/index.less +48 -0
- package/apps/client/src/components/QuickCommands/index.tsx +22 -0
- package/apps/client/src/components/ReasoningBlock/index.less +90 -0
- package/apps/client/src/components/ReasoningBlock/index.tsx +56 -0
- package/apps/client/src/components/ScrollToBottomButton/index.less +32 -0
- package/apps/client/src/components/ScrollToBottomButton/index.tsx +10 -0
- package/apps/client/src/components/ToolCallBlock/index.less +91 -0
- package/apps/client/src/components/ToolCallBlock/index.tsx +63 -0
- package/apps/client/src/examples/tourism/TourismLanding.tsx +96 -0
- package/apps/client/src/examples/tourism/index.less +192 -0
- package/apps/client/src/hooks/useMediaUpload.ts +237 -0
- package/apps/client/src/hooks/usePullDownHistory.ts +97 -0
- package/apps/client/src/hooks/useTTSAutoPlay.ts +80 -0
- package/apps/client/src/main.tsx +17 -0
- package/apps/client/src/modules.ts +30 -0
- package/apps/client/src/pages/index/index.less +43 -0
- package/apps/client/src/pages/index/index.tsx +145 -0
- package/apps/client/src/platform/AgentChatApp.tsx +39 -0
- package/apps/client/src/platform/agent-runtime.ts +15 -0
- package/apps/client/src/platform/state/createPersistedStore.ts +19 -0
- package/apps/client/src/shims/node-stub.ts +14 -0
- package/apps/client/src/store/chat-config.ts +29 -0
- package/apps/client/src/store/chat.ts +201 -0
- package/apps/client/src/styles/global.less +60 -0
- package/apps/client/src/styles/tokens.less +60 -0
- package/apps/client/src/types/chat.ts +29 -0
- package/apps/client/src/utils/copyText.ts +11 -0
- package/apps/client/src/utils/imagePreview.ts +10 -0
- package/apps/client/src/utils/session-error.ts +5 -0
- package/apps/client/src/utils/toast.ts +15 -0
- package/apps/client/src/vite-env.d.ts +3 -0
- package/apps/client/tsconfig.json +11 -0
- package/apps/client/vite.config.ts +40 -0
- package/apps/server/package.json +40 -0
- package/apps/server/src/cli/collect-registry.ts +26 -0
- package/apps/server/src/cli/plugin-doctor.ts +89 -0
- package/apps/server/src/cli/plugin-inspect.ts +49 -0
- package/apps/server/src/index.ts +230 -0
- package/apps/server/src/intent-routes.ts +23 -0
- package/apps/server/src/modules.ts +31 -0
- package/apps/server/src/tools/translate.ts +41 -0
- package/apps/server/tests/cards-registry.test.ts +55 -0
- package/apps/server/tests/tool-runtime.test.ts +49 -0
- package/apps/server/tsconfig.json +5 -0
- package/apps/server/tsup.config.ts +25 -0
- package/apps/server/vitest.config.ts +8 -0
- package/config/chat.json +8 -0
- package/config/llm_provider.json +3 -0
- package/config/plugins.json +3 -0
- package/eslint.config.mjs +41 -0
- package/package.json +51 -0
- package/packages/module-weather/package.json +35 -0
- package/packages/module-weather/src/client/cards/weather/index.less +22 -0
- package/packages/module-weather/src/client/cards/weather/index.tsx +14 -0
- package/packages/module-weather/src/client/index.ts +19 -0
- package/packages/module-weather/src/index.ts +2 -0
- package/packages/module-weather/src/server/cards/weather/meta.ts +28 -0
- package/packages/module-weather/src/server/index.ts +19 -0
- package/packages/module-weather/src/server/tool.ts +29 -0
- package/packages/module-weather/tbox.component.json +27 -0
- package/packages/module-weather/tsconfig.json +14 -0
- package/pnpm-workspace.template.yaml +60 -0
- package/scripts/build.sh +39 -0
- package/scripts/check-inspector.sh +91 -0
- package/scripts/check-workspace.mjs +164 -0
- package/scripts/dev.sh +15 -0
- package/scripts/prepare.sh +11 -0
- package/scripts/start.sh +11 -0
- package/scripts/stop.sh +26 -0
- package/scripts/verify-generated.mjs +142 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useUploadAdapter, useRefreshSession, chooseImage, chooseVideo } from '@tbox.cn/app-sdk/client';
|
|
3
|
+
|
|
4
|
+
import { isSessionExpiredError } from '@/utils/session-error';
|
|
5
|
+
import { toast } from '@/utils/toast';
|
|
6
|
+
import type { MediaItem, MediaType, MediaConfig } from '@/types/chat';
|
|
7
|
+
|
|
8
|
+
let seq = 0;
|
|
9
|
+
const nid = () => `media_${Date.now()}_${seq++}`;
|
|
10
|
+
const MAX_SIM_PROGRESS = 90; // 模拟进度上限;真正完成才到 100
|
|
11
|
+
|
|
12
|
+
/** 释放 blob: ObjectURL(仅浏览器环境;移除/清空媒体时调用防内存泄漏) */
|
|
13
|
+
const revokeIfBlob = (src?: string) => {
|
|
14
|
+
if (src && src.startsWith('blob:') && typeof URL !== 'undefined') {
|
|
15
|
+
URL.revokeObjectURL(src);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function fileToType(f: File): MediaType {
|
|
20
|
+
if (f.type.startsWith('image')) return 'image';
|
|
21
|
+
if (f.type.startsWith('video')) return 'video';
|
|
22
|
+
if (f.type.startsWith('audio')) return 'audio';
|
|
23
|
+
return 'file';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const typeLabel = (t: MediaType) =>
|
|
27
|
+
t === 'image' ? '图片' : t === 'video' ? '视频' : t === 'audio' ? '音频' : '文件';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 媒体选取 + 上传(走 upload adapter,对齐原模板行为)。
|
|
31
|
+
* - 浏览器:addFiles(File[])(来自 <input type=file>);支付宝壳:addFromBridge('image'|'camera'|'video')
|
|
32
|
+
* - 上传中模拟进度(驱动圆形进度环);成功填 fileId;safed===false → illegal(未过审);
|
|
33
|
+
* session 过期自动 refresh + 重试一次;失败可 retry。
|
|
34
|
+
* - 无 upload adapter(session 未就绪)时 Toast 提示,不静默吞掉。
|
|
35
|
+
*/
|
|
36
|
+
export function useMediaUpload(maxTotal = 9, mediaConfig?: MediaConfig) {
|
|
37
|
+
const adapter = useUploadAdapter();
|
|
38
|
+
const refreshSession = useRefreshSession();
|
|
39
|
+
const [items, setItems] = useState<MediaItem[]>([]);
|
|
40
|
+
const itemsRef = useRef(items);
|
|
41
|
+
itemsRef.current = items;
|
|
42
|
+
const sourceRef = useRef<Map<string, { source: File | string; type: MediaType; name?: string }>>(new Map());
|
|
43
|
+
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
44
|
+
|
|
45
|
+
const patch = useCallback((id: string, p: Partial<MediaItem>) => {
|
|
46
|
+
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...p } : it)));
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const clearTimer = (id: string) => {
|
|
50
|
+
const t = timersRef.current.get(id);
|
|
51
|
+
if (t !== undefined) {
|
|
52
|
+
clearTimeout(t);
|
|
53
|
+
timersRef.current.delete(id);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// 进度模拟:随机间隔 100-500ms、步长 5-15,累加到 MAX_SIM_PROGRESS
|
|
58
|
+
const startProgressSim = (id: string) => {
|
|
59
|
+
clearTimer(id);
|
|
60
|
+
let cur = 0;
|
|
61
|
+
const next = () => {
|
|
62
|
+
const delay = Math.floor(Math.random() * 401) + 100;
|
|
63
|
+
const t = setTimeout(() => {
|
|
64
|
+
cur = Math.min(cur + Math.floor(Math.random() * 11) + 5, MAX_SIM_PROGRESS);
|
|
65
|
+
patch(id, { uploadProgress: cur });
|
|
66
|
+
if (cur < MAX_SIM_PROGRESS) next();
|
|
67
|
+
else timersRef.current.delete(id);
|
|
68
|
+
}, delay);
|
|
69
|
+
timersRef.current.set(id, t);
|
|
70
|
+
};
|
|
71
|
+
next();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
useEffect(
|
|
75
|
+
() => () => {
|
|
76
|
+
timersRef.current.forEach((t) => clearTimeout(t));
|
|
77
|
+
timersRef.current.clear();
|
|
78
|
+
},
|
|
79
|
+
[],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const doUpload = useCallback(
|
|
83
|
+
(id: string) => {
|
|
84
|
+
const rec = sourceRef.current.get(id);
|
|
85
|
+
if (!adapter || !rec) return;
|
|
86
|
+
patch(id, { isUploading: true, uploadProgress: 0, uploadFailed: false, uploadError: undefined, illegal: false });
|
|
87
|
+
startProgressSim(id);
|
|
88
|
+
|
|
89
|
+
const done = (res: { fileId: string; mimeType?: string; safed?: boolean }) => {
|
|
90
|
+
clearTimer(id);
|
|
91
|
+
patch(id, {
|
|
92
|
+
fileId: res.fileId,
|
|
93
|
+
mimeType: res.mimeType,
|
|
94
|
+
isUploading: false,
|
|
95
|
+
uploadProgress: 100,
|
|
96
|
+
...(res.safed === false ? { illegal: true } : {}),
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
(async () => {
|
|
101
|
+
try {
|
|
102
|
+
done(await adapter.upload(rec.source, { type: rec.type, name: rec.name }));
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// session 过期 → 刷新并重试一次
|
|
105
|
+
if (isSessionExpiredError(err) && refreshSession) {
|
|
106
|
+
try {
|
|
107
|
+
const next = await refreshSession();
|
|
108
|
+
if (next.upload) {
|
|
109
|
+
done(await next.upload.upload(rec.source, { type: rec.type, name: rec.name }));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* 重试失败 → 落到下方失败处理 */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
clearTimer(id);
|
|
117
|
+
patch(id, { isUploading: false, uploadFailed: true, uploadError: String((err as Error)?.message || err) });
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
},
|
|
121
|
+
[adapter, refreshSession, patch],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const enqueue = useCallback(
|
|
125
|
+
(source: File | string, meta: { type: MediaType; src: string; name?: string; size?: number }) => {
|
|
126
|
+
if (!adapter) {
|
|
127
|
+
toast('上传暂不可用(未登录或未配置 TBOX 凭证)');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const id = nid();
|
|
131
|
+
sourceRef.current.set(id, { source, type: meta.type, name: meta.name });
|
|
132
|
+
setItems((prev) => [
|
|
133
|
+
...prev,
|
|
134
|
+
{ id, type: meta.type, src: meta.src, name: meta.name, size: meta.size, isUploading: true, uploadProgress: 0 },
|
|
135
|
+
]);
|
|
136
|
+
doUpload(id);
|
|
137
|
+
},
|
|
138
|
+
[adapter, doUpload],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const retry = useCallback((id: string) => doUpload(id), [doUpload]);
|
|
142
|
+
|
|
143
|
+
const room = () => Math.max(0, maxTotal - itemsRef.current.length);
|
|
144
|
+
|
|
145
|
+
// 校验单个待加项:超总数/分类型数量→'stop'(停止本批);超单文件大小→'skip'(跳过该文件)。
|
|
146
|
+
// 对齐原模板 useFileUpload 的 checkLimit + checkFileSize。
|
|
147
|
+
const validate = useCallback(
|
|
148
|
+
(
|
|
149
|
+
type: MediaType,
|
|
150
|
+
size: number | undefined,
|
|
151
|
+
total: number,
|
|
152
|
+
typeCount: number,
|
|
153
|
+
): 'ok' | 'skip' | 'stop' => {
|
|
154
|
+
if (total >= maxTotal) {
|
|
155
|
+
toast(`最多添加 ${maxTotal} 个文件`);
|
|
156
|
+
return 'stop';
|
|
157
|
+
}
|
|
158
|
+
const tc = mediaConfig?.[type];
|
|
159
|
+
if (tc && typeCount >= tc.maxCount) {
|
|
160
|
+
toast(`最多添加 ${tc.maxCount} 个${typeLabel(type)}`);
|
|
161
|
+
return 'stop';
|
|
162
|
+
}
|
|
163
|
+
if (tc && size && size > tc.maxSize) {
|
|
164
|
+
toast(`文件大小不能超过 ${(tc.maxSize / 1048576).toFixed(0)}MB`);
|
|
165
|
+
return 'skip';
|
|
166
|
+
}
|
|
167
|
+
return 'ok';
|
|
168
|
+
},
|
|
169
|
+
[maxTotal, mediaConfig],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const addFiles = useCallback(
|
|
173
|
+
(files: File[]) => {
|
|
174
|
+
let total = itemsRef.current.length;
|
|
175
|
+
const typeCounts: Record<string, number> = {};
|
|
176
|
+
for (const it of itemsRef.current) typeCounts[it.type] = (typeCounts[it.type] || 0) + 1;
|
|
177
|
+
for (const f of files) {
|
|
178
|
+
const type = fileToType(f);
|
|
179
|
+
const v = validate(type, f.size, total, typeCounts[type] || 0);
|
|
180
|
+
if (v === 'stop') break;
|
|
181
|
+
if (v === 'skip') continue;
|
|
182
|
+
enqueue(f, { type, src: URL.createObjectURL(f), name: f.name, size: f.size });
|
|
183
|
+
total += 1;
|
|
184
|
+
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
[enqueue, validate],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const addFromBridge = useCallback(
|
|
191
|
+
async (kind: 'image' | 'camera' | 'video') => {
|
|
192
|
+
if (!adapter) {
|
|
193
|
+
toast('上传暂不可用(未登录或未配置 TBOX 凭证)');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const res =
|
|
198
|
+
kind === 'video'
|
|
199
|
+
? await chooseVideo()
|
|
200
|
+
: await chooseImage(room(), kind === 'camera' ? ['camera'] : undefined);
|
|
201
|
+
const type: MediaType = kind === 'video' ? 'video' : 'image';
|
|
202
|
+
let total = itemsRef.current.length;
|
|
203
|
+
let typeCount = itemsRef.current.filter((it) => it.type === type).length;
|
|
204
|
+
for (const f of res?.files || []) {
|
|
205
|
+
if (!f.data) continue;
|
|
206
|
+
const v = validate(type, f.size, total, typeCount);
|
|
207
|
+
if (v === 'stop') break;
|
|
208
|
+
if (v === 'skip') continue;
|
|
209
|
+
enqueue(f.data, { type, src: f.data, name: f.name, size: f.size });
|
|
210
|
+
total += 1;
|
|
211
|
+
typeCount += 1;
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.warn('[media] bridge pick failed:', err);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
[adapter, enqueue, validate],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const removeItem = useCallback((id: string) => {
|
|
221
|
+
clearTimer(id);
|
|
222
|
+
const rec = itemsRef.current.find((it) => it.id === id);
|
|
223
|
+
revokeIfBlob(rec?.src);
|
|
224
|
+
sourceRef.current.delete(id);
|
|
225
|
+
setItems((prev) => prev.filter((it) => it.id !== id));
|
|
226
|
+
}, []);
|
|
227
|
+
const clear = useCallback(() => {
|
|
228
|
+
timersRef.current.forEach((t) => clearTimeout(t));
|
|
229
|
+
timersRef.current.clear();
|
|
230
|
+
itemsRef.current.forEach((it) => revokeIfBlob(it.src));
|
|
231
|
+
sourceRef.current.clear();
|
|
232
|
+
setItems([]);
|
|
233
|
+
}, []);
|
|
234
|
+
const uploading = items.some((it) => it.isUploading);
|
|
235
|
+
|
|
236
|
+
return { items, addFiles, addFromBridge, removeItem, retry, clear, uploading };
|
|
237
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export type PullStatus = 'idle' | 'pulling' | 'ready' | 'loading' | 'noMore';
|
|
4
|
+
|
|
5
|
+
const DAMPING = 0.4;
|
|
6
|
+
const THRESHOLD = 60;
|
|
7
|
+
|
|
8
|
+
interface Options {
|
|
9
|
+
isAtTopRef: React.MutableRefObject<boolean>;
|
|
10
|
+
isLoadingHistory: boolean;
|
|
11
|
+
hasMore: boolean;
|
|
12
|
+
onLoad: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 列表顶部下拉加载历史(纯 H5 触摸,去 Taro)。 */
|
|
16
|
+
export function usePullDownHistory({ isAtTopRef, isLoadingHistory, hasMore, onLoad }: Options) {
|
|
17
|
+
const [pullStatus, setPullStatus] = useState<PullStatus>('idle');
|
|
18
|
+
const [pullDistance, setPullDistance] = useState(0);
|
|
19
|
+
const startYRef = useRef(0);
|
|
20
|
+
const isPullingRef = useRef(false);
|
|
21
|
+
|
|
22
|
+
const handleTouchStart = useCallback(
|
|
23
|
+
(e: React.TouchEvent) => {
|
|
24
|
+
if (isLoadingHistory || pullStatus === 'loading') return;
|
|
25
|
+
const touch = e.touches[0];
|
|
26
|
+
if (!touch) return;
|
|
27
|
+
startYRef.current = touch.clientY;
|
|
28
|
+
isPullingRef.current = isAtTopRef.current;
|
|
29
|
+
},
|
|
30
|
+
[isLoadingHistory, pullStatus, isAtTopRef],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const handleTouchMove = useCallback(
|
|
34
|
+
(e: React.TouchEvent) => {
|
|
35
|
+
if (!isPullingRef.current) return;
|
|
36
|
+
if (isLoadingHistory || pullStatus === 'loading') return;
|
|
37
|
+
if (!hasMore && pullStatus !== 'pulling' && pullStatus !== 'ready') return;
|
|
38
|
+
|
|
39
|
+
const touch = e.touches[0];
|
|
40
|
+
if (!touch) return;
|
|
41
|
+
const deltaY = touch.clientY - startYRef.current;
|
|
42
|
+
|
|
43
|
+
if (deltaY <= 0) {
|
|
44
|
+
if (pullStatus !== 'idle') {
|
|
45
|
+
setPullStatus('idle');
|
|
46
|
+
setPullDistance(0);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
} catch {
|
|
54
|
+
/* passive listener, ignore */
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const distance = deltaY * DAMPING;
|
|
58
|
+
if (!hasMore) {
|
|
59
|
+
setPullStatus('noMore');
|
|
60
|
+
setPullDistance(Math.min(distance, THRESHOLD));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setPullDistance(distance);
|
|
64
|
+
setPullStatus(distance > THRESHOLD ? 'ready' : 'pulling');
|
|
65
|
+
},
|
|
66
|
+
[isLoadingHistory, pullStatus, hasMore],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const handleTouchEnd = useCallback(() => {
|
|
70
|
+
if (!isPullingRef.current) return;
|
|
71
|
+
isPullingRef.current = false;
|
|
72
|
+
|
|
73
|
+
if (pullStatus === 'ready' && hasMore) {
|
|
74
|
+
setPullStatus('loading');
|
|
75
|
+
setPullDistance(THRESHOLD);
|
|
76
|
+
onLoad()
|
|
77
|
+
.then(() => {
|
|
78
|
+
setPullStatus('idle');
|
|
79
|
+
setPullDistance(0);
|
|
80
|
+
})
|
|
81
|
+
.catch(() => {
|
|
82
|
+
setPullStatus('idle');
|
|
83
|
+
setPullDistance(0);
|
|
84
|
+
});
|
|
85
|
+
} else if (pullStatus === 'noMore') {
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
setPullStatus('idle');
|
|
88
|
+
setPullDistance(0);
|
|
89
|
+
}, 1500);
|
|
90
|
+
} else {
|
|
91
|
+
setPullStatus('idle');
|
|
92
|
+
setPullDistance(0);
|
|
93
|
+
}
|
|
94
|
+
}, [pullStatus, hasMore, onLoad]);
|
|
95
|
+
|
|
96
|
+
return { pullStatus, pullDistance, handleTouchStart, handleTouchMove, handleTouchEnd };
|
|
97
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useChatStore } from '@/store/chat';
|
|
3
|
+
import { useTTSAdapter } from '@tbox.cn/app-sdk/client';
|
|
4
|
+
|
|
5
|
+
const FLUSH_MS = 300; // 累积 delta 后批量送,避免过多 WS 消息
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TTS 自动播放:开启后订阅流式消息,**边生成边送** TTS 流式播放(对齐原模板的流式自动朗读)。
|
|
9
|
+
* 关闭 / 卸载时停止。
|
|
10
|
+
*/
|
|
11
|
+
export function useTTSAutoPlay(enabled: boolean) {
|
|
12
|
+
const tts = useTTSAdapter();
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!enabled || !tts || !tts.startStreaming) return;
|
|
16
|
+
|
|
17
|
+
let activeId: string | null = null;
|
|
18
|
+
let prevContent = '';
|
|
19
|
+
let pending = '';
|
|
20
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
|
|
22
|
+
const clearTimer = () => {
|
|
23
|
+
if (timer) {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
timer = null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const flush = () => {
|
|
29
|
+
clearTimer();
|
|
30
|
+
if (pending) {
|
|
31
|
+
tts.sendText?.(pending);
|
|
32
|
+
pending = '';
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const enqueue = (delta: string) => {
|
|
36
|
+
pending += delta;
|
|
37
|
+
if (!timer) timer = setTimeout(flush, FLUSH_MS);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handle = (state: ReturnType<typeof useChatStore.getState>) => {
|
|
41
|
+
const { messages, streamingMsgId } = state;
|
|
42
|
+
const msg = messages.find((m) => m.id === streamingMsgId);
|
|
43
|
+
|
|
44
|
+
// 流结束 / 无流式消息 → 结束这轮
|
|
45
|
+
if (!msg || msg.status === 'done') {
|
|
46
|
+
if (activeId) {
|
|
47
|
+
flush();
|
|
48
|
+
tts.finishStreaming?.();
|
|
49
|
+
activeId = null;
|
|
50
|
+
prevContent = '';
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// 新流式消息 → 开一轮
|
|
55
|
+
if (msg.status === 'streaming' && msg.id !== activeId) {
|
|
56
|
+
tts.stop();
|
|
57
|
+
clearTimer();
|
|
58
|
+
pending = '';
|
|
59
|
+
activeId = msg.id;
|
|
60
|
+
prevContent = '';
|
|
61
|
+
tts.startStreaming?.();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// 内容增量 → 入队
|
|
65
|
+
if (activeId === msg.id && msg.content.length > prevContent.length) {
|
|
66
|
+
enqueue(msg.content.slice(prevContent.length));
|
|
67
|
+
prevContent = msg.content;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const unsub = useChatStore.subscribe(handle);
|
|
72
|
+
handle(useChatStore.getState());
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
unsub();
|
|
76
|
+
clearTimer();
|
|
77
|
+
if (activeId) tts.stop();
|
|
78
|
+
};
|
|
79
|
+
}, [enabled, tts]);
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { configureRuntime } from '@tbox.cn/app-sdk/client';
|
|
4
|
+
import App from './app';
|
|
5
|
+
import './styles/global.less';
|
|
6
|
+
|
|
7
|
+
// H1:环境值注入点(SDK 零 Vite 依赖)——VITE_API_URL 空串 = 同源相对路径
|
|
8
|
+
configureRuntime({ apiBaseUrl: import.meta.env.VITE_API_URL as string | undefined });
|
|
9
|
+
|
|
10
|
+
const el = document.getElementById('root');
|
|
11
|
+
if (el) {
|
|
12
|
+
createRoot(el).render(
|
|
13
|
+
<StrictMode>
|
|
14
|
+
<App />
|
|
15
|
+
</StrictMode>,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端模块注册装配文件(与 apps/server/src/modules.ts 逐行同构)。
|
|
3
|
+
* tbox CLI 协助维护锚点区;你也可以直接编辑自由区。
|
|
4
|
+
*
|
|
5
|
+
* 锚点区:@tbox:imports-begin/end(模块 import 行)+ @tbox:register-begin/end(模块数组)
|
|
6
|
+
* CLI 行为:增量插入/删除模块行,保留非标行。
|
|
7
|
+
*/
|
|
8
|
+
import type { ClientContext, InferModuleCards } from '@tbox.cn/app-sdk/client';
|
|
9
|
+
|
|
10
|
+
// @tbox:imports-begin
|
|
11
|
+
import { clientModule as moduleWeather } from '@app/module-weather/client';
|
|
12
|
+
// @tbox:imports-end
|
|
13
|
+
|
|
14
|
+
// @tbox:register-begin
|
|
15
|
+
const modules = [
|
|
16
|
+
moduleWeather,
|
|
17
|
+
];
|
|
18
|
+
// @tbox:register-end
|
|
19
|
+
|
|
20
|
+
// 自由区:可做装配级定制——条件注册、顺序调整
|
|
21
|
+
|
|
22
|
+
/** 注册所有已安装模块(客户端装配——勿与 apps/server/src/modules.ts 混淆) */
|
|
23
|
+
export function registerAll(ctx: ClientContext): void {
|
|
24
|
+
for (const m of modules) {
|
|
25
|
+
m.register(ctx);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 类型从运行时装配数组自动推导:增删模块后 CardType 自动收缩/扩展(002 §6.7)
|
|
30
|
+
export type CardType = InferModuleCards<(typeof modules)[number]>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.chat-page {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
height: 100%;
|
|
5
|
+
position: relative;
|
|
6
|
+
background: #eef2f9;
|
|
7
|
+
|
|
8
|
+
&__bg {
|
|
9
|
+
position: absolute;
|
|
10
|
+
inset: 0;
|
|
11
|
+
z-index: 0;
|
|
12
|
+
background-image: linear-gradient(180deg, #9dc6ff 0%, #e5f0ff 63%, #e5f0ff 100%);
|
|
13
|
+
opacity: 0.66;
|
|
14
|
+
pointer-events: none;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
&__body-wrapper {
|
|
18
|
+
flex: 1;
|
|
19
|
+
min-height: 0;
|
|
20
|
+
position: relative;
|
|
21
|
+
z-index: 1;
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
&__body {
|
|
27
|
+
flex: 1;
|
|
28
|
+
min-height: 0;
|
|
29
|
+
overflow-y: auto;
|
|
30
|
+
overflow-anchor: none;
|
|
31
|
+
overscroll-behavior: contain;
|
|
32
|
+
position: relative;
|
|
33
|
+
padding: 10px 0 8px;
|
|
34
|
+
width: 100%;
|
|
35
|
+
max-width: var(--tbox-content-max-width);
|
|
36
|
+
margin: 0 auto;
|
|
37
|
+
scrollbar-width: none;
|
|
38
|
+
|
|
39
|
+
&::-webkit-scrollbar {
|
|
40
|
+
display: none;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type ComponentType } from 'react';
|
|
2
|
+
import { useChatStore } from '@/store/chat';
|
|
3
|
+
import { useTTSAutoPlay } from '@/hooks/useTTSAutoPlay';
|
|
4
|
+
import { MessageBubble } from '@/components/MessageBubble';
|
|
5
|
+
import { ChatInput } from '@/components/ChatInput';
|
|
6
|
+
import { QuickCommands } from '@/components/QuickCommands';
|
|
7
|
+
import ChatHeader from '@/components/ChatHeader';
|
|
8
|
+
import ScrollToBottomButton from '@/components/ScrollToBottomButton';
|
|
9
|
+
import PullDownIndicator from '@/components/PullDownIndicator';
|
|
10
|
+
import { usePullDownHistory } from '@/hooks/usePullDownHistory';
|
|
11
|
+
import { CardRenderer } from '@/cards/CardRenderer';
|
|
12
|
+
import type { ChatInputMessage } from '@/types/chat';
|
|
13
|
+
import './index.less';
|
|
14
|
+
|
|
15
|
+
export interface ChatLandingProps {
|
|
16
|
+
onSend: (input: string | ChatInputMessage) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ChatLandingComponent = ComponentType<ChatLandingProps>;
|
|
20
|
+
|
|
21
|
+
interface IndexPageProps {
|
|
22
|
+
landing?: ChatLandingComponent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function IndexPage({ landing: Landing }: IndexPageProps) {
|
|
26
|
+
const messages = useChatStore((s) => s.messages);
|
|
27
|
+
const historyMessages = useChatStore((s) => s.historyMessages);
|
|
28
|
+
const historyHasMore = useChatStore((s) => s.historyHasMore);
|
|
29
|
+
const isLoadingHistory = useChatStore((s) => s.isLoadingHistory);
|
|
30
|
+
const loadMoreHistory = useChatStore((s) => s.loadMoreHistory);
|
|
31
|
+
const isGenerating = useChatStore((s) => s.isGenerating);
|
|
32
|
+
const orphanCards = useChatStore((s) => s.orphanCards);
|
|
33
|
+
const initConversation = useChatStore((s) => s.initConversation);
|
|
34
|
+
const destroy = useChatStore((s) => s.destroy);
|
|
35
|
+
const sendMessage = useChatStore((s) => s.sendMessage);
|
|
36
|
+
const cancelGeneration = useChatStore((s) => s.cancelGeneration);
|
|
37
|
+
const clearChat = useChatStore((s) => s.clearChat);
|
|
38
|
+
|
|
39
|
+
const [ttsAutoPlay, setTtsAutoPlay] = useState(false);
|
|
40
|
+
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
|
41
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const isAtTopRef = useRef(false);
|
|
43
|
+
const isNearBottomRef = useRef(true);
|
|
44
|
+
|
|
45
|
+
// TTS 自动播放:开启后边生成边流式朗读
|
|
46
|
+
useTTSAutoPlay(ttsAutoPlay);
|
|
47
|
+
|
|
48
|
+
const send = useCallback((input: string | ChatInputMessage) => sendMessage(input), [sendMessage]);
|
|
49
|
+
|
|
50
|
+
const scrollToBottom = useCallback(() => {
|
|
51
|
+
const el = bodyRef.current;
|
|
52
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
53
|
+
isNearBottomRef.current = true;
|
|
54
|
+
setShowScrollToBottom(false);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const NEAR_BOTTOM = 150;
|
|
58
|
+
const onScroll = useCallback(() => {
|
|
59
|
+
const el = bodyRef.current;
|
|
60
|
+
if (!el) return;
|
|
61
|
+
isAtTopRef.current = el.scrollTop <= 5;
|
|
62
|
+
const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
63
|
+
isNearBottomRef.current = distanceToBottom < NEAR_BOTTOM;
|
|
64
|
+
setShowScrollToBottom(!isNearBottomRef.current);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
// 下拉加载历史:加载时保持滚动位置(记录加载前高度,加载后补偿)
|
|
68
|
+
const onLoadHistory = useCallback(async () => {
|
|
69
|
+
const el = bodyRef.current;
|
|
70
|
+
const prevHeight = el?.scrollHeight ?? 0;
|
|
71
|
+
await loadMoreHistory();
|
|
72
|
+
requestAnimationFrame(() => {
|
|
73
|
+
const cur = bodyRef.current;
|
|
74
|
+
if (cur) cur.scrollTop += cur.scrollHeight - prevHeight;
|
|
75
|
+
});
|
|
76
|
+
}, [loadMoreHistory]);
|
|
77
|
+
|
|
78
|
+
const pull = usePullDownHistory({
|
|
79
|
+
isAtTopRef,
|
|
80
|
+
isLoadingHistory,
|
|
81
|
+
hasMore: historyHasMore,
|
|
82
|
+
onLoad: onLoadHistory,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
initConversation();
|
|
87
|
+
return () => destroy();
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// 仅在贴近底部时自动滚动,避免打断用户上翻查看历史
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (isNearBottomRef.current) {
|
|
94
|
+
const el = bodyRef.current;
|
|
95
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
96
|
+
}
|
|
97
|
+
}, [messages, orphanCards]);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="chat-page">
|
|
101
|
+
<div className="chat-page__bg" />
|
|
102
|
+
<ChatHeader
|
|
103
|
+
ttsAutoPlay={ttsAutoPlay}
|
|
104
|
+
onToggleTTS={() => setTtsAutoPlay((v) => !v)}
|
|
105
|
+
onClearChat={clearChat}
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<div className="chat-page__body-wrapper">
|
|
109
|
+
<div
|
|
110
|
+
className="chat-page__body"
|
|
111
|
+
ref={bodyRef}
|
|
112
|
+
onScroll={onScroll}
|
|
113
|
+
onTouchStart={pull.handleTouchStart}
|
|
114
|
+
onTouchMove={pull.handleTouchMove}
|
|
115
|
+
onTouchEnd={pull.handleTouchEnd}
|
|
116
|
+
>
|
|
117
|
+
{/* 下拉加载历史指示器 */}
|
|
118
|
+
<PullDownIndicator status={pull.pullStatus} distance={pull.pullDistance} />
|
|
119
|
+
|
|
120
|
+
{/* 历史消息(下拉加载) */}
|
|
121
|
+
{historyMessages.map((msg) => (
|
|
122
|
+
<MessageBubble key={msg.id} message={msg} />
|
|
123
|
+
))}
|
|
124
|
+
|
|
125
|
+
{Landing ? <Landing onSend={send} /> : null}
|
|
126
|
+
|
|
127
|
+
{/* 业务旁路 emit 的卡片(如欢迎卡) */}
|
|
128
|
+
{orphanCards.map((card) => (
|
|
129
|
+
<CardRenderer key={card.id} card={card} />
|
|
130
|
+
))}
|
|
131
|
+
|
|
132
|
+
{/* 消息(每条消息下的卡片由 MessageBubble 内部渲染) */}
|
|
133
|
+
{messages.map((msg) => (
|
|
134
|
+
<MessageBubble key={msg.id} message={msg} />
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<ScrollToBottomButton visible={showScrollToBottom && isGenerating} onClick={scrollToBottom} />
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<QuickCommands onCommand={send} />
|
|
142
|
+
<ChatInput generating={isGenerating} onSend={send} onCancel={cancelGeneration} onNewChat={clearChat} />
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { AdapterProvider, createPlatformAdapters, type AdapterContextValue } from '@tbox.cn/app-sdk/client';
|
|
3
|
+
import type { TboxSessionData } from '@tbox.cn/app-contracts';
|
|
4
|
+
import IndexPage, { type ChatLandingComponent } from '@/pages/index/index';
|
|
5
|
+
import { authClient, platformApi } from './agent-runtime';
|
|
6
|
+
|
|
7
|
+
/** 平台默认的全栈对话运行时。普通本地应用无需读取或修改。 */
|
|
8
|
+
export default function AgentChatApp({ landing }: { landing?: ChatLandingComponent }) {
|
|
9
|
+
const [session, setSession] = useState<TboxSessionData | null>(null);
|
|
10
|
+
// TTS 不依赖 session,恒可用且保持同一实例(播放状态不因会话刷新丢失)
|
|
11
|
+
const tts = useMemo(() => createPlatformAdapters().tts, []);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
authClient
|
|
15
|
+
.ensureAuth()
|
|
16
|
+
.then((auth) => platformApi.getTboxSession(auth.identity.userId))
|
|
17
|
+
.then(setSession)
|
|
18
|
+
.catch((err) => console.error('[AgentChatApp] tbox session init failed:', err));
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
const refreshSession = useCallback(async (): Promise<AdapterContextValue> => {
|
|
22
|
+
platformApi.clearTboxSession();
|
|
23
|
+
const auth = await authClient.refreshAuth();
|
|
24
|
+
const data = await platformApi.getTboxSession(auth.identity.userId, true);
|
|
25
|
+
setSession(data);
|
|
26
|
+
return { tts, ...createPlatformAdapters(data, { tts }), refreshSession };
|
|
27
|
+
}, [tts]);
|
|
28
|
+
|
|
29
|
+
const adapters = useMemo<AdapterContextValue>(() => {
|
|
30
|
+
if (!session) return { tts, refreshSession };
|
|
31
|
+
return { tts, ...createPlatformAdapters(session, { tts }), refreshSession };
|
|
32
|
+
}, [session, tts, refreshSession]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<AdapterProvider value={adapters}>
|
|
36
|
+
<IndexPage landing={landing} />
|
|
37
|
+
</AdapterProvider>
|
|
38
|
+
);
|
|
39
|
+
}
|