@amaster.ai/components-templates 1.6.0 → 1.10.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/components/ai-assistant/package.json +10 -12
- package/components/ai-assistant/template/ai-assistant.tsx +48 -7
- package/components/ai-assistant/template/components/chat-assistant-message.tsx +78 -7
- package/components/ai-assistant/template/components/chat-display-mode-switcher.tsx +35 -11
- package/components/ai-assistant/template/components/chat-floating-button.tsx +2 -1
- package/components/ai-assistant/template/components/chat-floating-card.tsx +49 -3
- package/components/ai-assistant/template/components/chat-header.tsx +1 -1
- package/components/ai-assistant/template/components/chat-input.tsx +57 -22
- package/components/ai-assistant/template/components/chat-messages.tsx +118 -25
- package/components/ai-assistant/template/components/chat-recommends.tsx +79 -15
- package/components/ai-assistant/template/components/voice-input.tsx +11 -2
- package/components/ai-assistant/template/hooks/useAssistantSize.ts +360 -0
- package/components/ai-assistant/template/hooks/useConversation.ts +0 -23
- package/components/ai-assistant/template/hooks/useDisplayMode.tsx +52 -5
- package/components/ai-assistant/template/hooks/useDraggable.ts +11 -3
- package/components/ai-assistant/template/hooks/usePosition.ts +19 -31
- package/components/ai-assistant/template/i18n.ts +8 -0
- package/components/ai-assistant/template/types.ts +2 -0
- package/components/ai-assistant-taro/package.json +16 -8
- package/components/ai-assistant-taro/template/components/ChatAssistantMessage.tsx +24 -2
- package/components/ai-assistant-taro/template/components/ChatInput.tsx +50 -28
- package/components/ai-assistant-taro/template/components/RecommendedQuestions.tsx +39 -0
- package/components/ai-assistant-taro/template/components/markdown.tsx +343 -137
- package/components/ai-assistant-taro/template/hooks/useConversation.ts +542 -424
- package/components/ai-assistant-taro/template/index.tsx +2 -2
- package/components/ai-assistant-taro/template/types.ts +16 -0
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
|
@@ -391,7 +391,6 @@ export function useConversation() {
|
|
|
391
391
|
try {
|
|
392
392
|
const partData = JSON.parse(json);
|
|
393
393
|
if (partData.root && partData.elements) {
|
|
394
|
-
debugger
|
|
395
394
|
fillData({
|
|
396
395
|
kind: 'ui-render',
|
|
397
396
|
taskId,
|
|
@@ -768,28 +767,6 @@ export function useConversation() {
|
|
|
768
767
|
const controller = abortControllerRef.current;
|
|
769
768
|
forceStopRef.current = false;
|
|
770
769
|
|
|
771
|
-
// mock
|
|
772
|
-
if (userContent === "MAGA") {
|
|
773
|
-
const { generateMockUIStream } = await import("../mock/mock-data");
|
|
774
|
-
const cancelMock = generateMockUIStream(
|
|
775
|
-
taskId || generateId(),
|
|
776
|
-
(chunk) => {
|
|
777
|
-
if (forceStopRef.current) return;
|
|
778
|
-
hasResponse = true;
|
|
779
|
-
processLiveData(chunk as any);
|
|
780
|
-
if (chunk.result.final) {
|
|
781
|
-
setIsLoading(false);
|
|
782
|
-
}
|
|
783
|
-
},
|
|
784
|
-
);
|
|
785
|
-
abortControllerRef.current?.signal.addEventListener(
|
|
786
|
-
"abort",
|
|
787
|
-
cancelMock,
|
|
788
|
-
);
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// real chat
|
|
793
770
|
try {
|
|
794
771
|
const stream = client.copilot.chat(
|
|
795
772
|
userContent ? [{ role: "user", content: userContent }] : [],
|
|
@@ -2,13 +2,50 @@ import { useEffect, useState } from "react";
|
|
|
2
2
|
import type { IDisplayMode } from "../types";
|
|
3
3
|
|
|
4
4
|
const getStorageKey = () => `${location.hostname}-ai-assistant-display-mode`;
|
|
5
|
+
const MOBILE_DISPLAY_MODES: IDisplayMode[] = [
|
|
6
|
+
"fullscreen",
|
|
7
|
+
"half-top",
|
|
8
|
+
"half-bottom",
|
|
9
|
+
];
|
|
10
|
+
const DESKTOP_DEFAULT_MODE: IDisplayMode = "floating";
|
|
11
|
+
const MOBILE_DEFAULT_MODE: IDisplayMode = "fullscreen";
|
|
5
12
|
|
|
6
|
-
|
|
13
|
+
const normalizeDisplayMode = (
|
|
14
|
+
mode: IDisplayMode | null,
|
|
15
|
+
isMobile: boolean,
|
|
16
|
+
): IDisplayMode => {
|
|
17
|
+
if (!mode) {
|
|
18
|
+
return isMobile ? MOBILE_DEFAULT_MODE : DESKTOP_DEFAULT_MODE;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (isMobile) {
|
|
22
|
+
return MOBILE_DISPLAY_MODES.includes(mode) ? mode : MOBILE_DEFAULT_MODE;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (mode === "half-top" || mode === "half-bottom") {
|
|
26
|
+
return DESKTOP_DEFAULT_MODE;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return mode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const useDisplayMode = (
|
|
33
|
+
enabled: boolean,
|
|
34
|
+
isMobile: boolean,
|
|
35
|
+
sideWidths?: { left: number; right: number },
|
|
36
|
+
) => {
|
|
7
37
|
const STORAGE_KEY = getStorageKey();
|
|
8
38
|
const [displayMode, setDisplayMode] = useState<IDisplayMode>(
|
|
9
|
-
(
|
|
39
|
+
normalizeDisplayMode(
|
|
40
|
+
localStorage.getItem(STORAGE_KEY) as IDisplayMode | null,
|
|
41
|
+
isMobile,
|
|
42
|
+
),
|
|
10
43
|
);
|
|
11
44
|
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setDisplayMode((currentMode) => normalizeDisplayMode(currentMode, isMobile));
|
|
47
|
+
}, [isMobile]);
|
|
48
|
+
|
|
12
49
|
useEffect(() => {
|
|
13
50
|
localStorage.setItem(STORAGE_KEY, displayMode);
|
|
14
51
|
|
|
@@ -26,15 +63,25 @@ export const useDisplayMode = (enabled: boolean) => {
|
|
|
26
63
|
if (enabled) {
|
|
27
64
|
if (displayMode === "side-left") {
|
|
28
65
|
layoutRoot.setAttribute("data-display-mode", "side-left");
|
|
66
|
+
layoutRoot.style.setProperty(
|
|
67
|
+
"--ai-assistant-side-left-width",
|
|
68
|
+
`${sideWidths?.left ?? 420}px`,
|
|
69
|
+
);
|
|
29
70
|
} else if (displayMode === "side-right") {
|
|
30
71
|
layoutRoot.setAttribute("data-display-mode", "side-right");
|
|
72
|
+
layoutRoot.style.setProperty(
|
|
73
|
+
"--ai-assistant-side-right-width",
|
|
74
|
+
`${sideWidths?.right ?? 420}px`,
|
|
75
|
+
);
|
|
31
76
|
}
|
|
32
77
|
}
|
|
33
78
|
|
|
34
79
|
return () => {
|
|
35
80
|
removeAttr();
|
|
81
|
+
layoutRoot.style.removeProperty("--ai-assistant-side-left-width");
|
|
82
|
+
layoutRoot.style.removeProperty("--ai-assistant-side-right-width");
|
|
36
83
|
};
|
|
37
|
-
}, [displayMode, enabled]);
|
|
84
|
+
}, [displayMode, enabled, sideWidths?.left, sideWidths?.right]);
|
|
38
85
|
|
|
39
86
|
const removeStyle = () => {
|
|
40
87
|
const style = document.getElementById("ai-assistant-display-mode-style");
|
|
@@ -49,13 +96,13 @@ export const useDisplayMode = (enabled: boolean) => {
|
|
|
49
96
|
style.id = "ai-assistant-display-mode-style";
|
|
50
97
|
style.innerHTML = `
|
|
51
98
|
[data-display-mode="side-left"] {
|
|
52
|
-
padding-left: 420px !important;
|
|
99
|
+
padding-left: var(--ai-assistant-side-left-width, 420px) !important;
|
|
53
100
|
padding-right: 0 !important;
|
|
54
101
|
}
|
|
55
102
|
|
|
56
103
|
[data-display-mode="side-right"] {
|
|
57
104
|
padding-left: 0 !important;
|
|
58
|
-
padding-right: 420px !important;
|
|
105
|
+
padding-right: var(--ai-assistant-side-right-width, 420px) !important;
|
|
59
106
|
}
|
|
60
107
|
`;
|
|
61
108
|
document.head.appendChild(style);
|
|
@@ -52,6 +52,9 @@ export const useDraggable = ({
|
|
|
52
52
|
const touch = e.touches[0];
|
|
53
53
|
if (!touch) return;
|
|
54
54
|
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
|
|
55
58
|
const actualPos = getActualPosition();
|
|
56
59
|
dragRef.current = {
|
|
57
60
|
startX: touch.clientX,
|
|
@@ -86,15 +89,20 @@ export const useDraggable = ({
|
|
|
86
89
|
const handleTouchMove = (e: TouchEvent) => {
|
|
87
90
|
const touch = e.touches[0];
|
|
88
91
|
if (touch) {
|
|
92
|
+
e.preventDefault();
|
|
89
93
|
handleMove(touch.clientX, touch.clientY);
|
|
90
94
|
}
|
|
91
95
|
};
|
|
92
96
|
|
|
93
|
-
const handleEnd = (event) => {
|
|
97
|
+
const handleEnd = (event: MouseEvent | TouchEvent) => {
|
|
94
98
|
if (dragRef.current) {
|
|
95
99
|
setPosition((prev) => {
|
|
100
|
+
const fallbackPoint =
|
|
101
|
+
"changedTouches" in event ? event.changedTouches[0] : event;
|
|
96
102
|
const snapped = snapToEdge(
|
|
97
|
-
prev.x === -1 && prev.y === -1
|
|
103
|
+
prev.x === -1 && prev.y === -1 && fallbackPoint
|
|
104
|
+
? { x: fallbackPoint.clientX, y: fallbackPoint.clientY }
|
|
105
|
+
: prev,
|
|
98
106
|
);
|
|
99
107
|
savePosition(snapped);
|
|
100
108
|
return snapped;
|
|
@@ -106,7 +114,7 @@ export const useDraggable = ({
|
|
|
106
114
|
|
|
107
115
|
document.addEventListener("mousemove", handleMouseMove);
|
|
108
116
|
document.addEventListener("mouseup", handleEnd);
|
|
109
|
-
document.addEventListener("touchmove", handleTouchMove, { passive:
|
|
117
|
+
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
110
118
|
document.addEventListener("touchend", handleEnd);
|
|
111
119
|
|
|
112
120
|
return () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect,
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import type { Position } from "../types";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_POSITION: Position = { x: -1, y: -1 };
|
|
@@ -34,23 +34,14 @@ const savePosition = (pos: Position) => {
|
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
export const usePosition = (
|
|
37
|
+
export const usePosition = (
|
|
38
|
+
isOpen: boolean,
|
|
39
|
+
isFullscreen: boolean,
|
|
40
|
+
getElementDimensions: () => { width: number; height: number },
|
|
41
|
+
) => {
|
|
38
42
|
const [position, setPosition] = useState<Position>(getSavedPosition);
|
|
39
|
-
// 使用 ref 来跟踪是否已经初始化,避免重复执行
|
|
40
|
-
const initializedRef = useRef(false);
|
|
41
43
|
const edgeDistance = 16; // 距离边缘的最小距离
|
|
42
44
|
|
|
43
|
-
const getElementDimensions = useCallback(() => {
|
|
44
|
-
const buttonSize = 56;
|
|
45
|
-
const dialogWidth = 420;
|
|
46
|
-
const dialogHeight =
|
|
47
|
-
window.innerHeight < 600 ? window.innerHeight : window.innerHeight - 200;
|
|
48
|
-
return {
|
|
49
|
-
width: isOpen ? dialogWidth : buttonSize,
|
|
50
|
-
height: isOpen ? dialogHeight : buttonSize,
|
|
51
|
-
};
|
|
52
|
-
}, [isOpen]);
|
|
53
|
-
|
|
54
45
|
const getActualPosition = useCallback((): Position => {
|
|
55
46
|
if (position.x < 0 || position.y < 0) {
|
|
56
47
|
return {
|
|
@@ -149,7 +140,8 @@ export const usePosition = (isOpen: boolean, isFullscreen: boolean) => {
|
|
|
149
140
|
const handleResize = () => {
|
|
150
141
|
setPosition((prev) => {
|
|
151
142
|
if (prev.x < 0 || prev.y < 0) return prev;
|
|
152
|
-
|
|
143
|
+
const clamped = clampToBounds(prev);
|
|
144
|
+
return clamped.x === prev.x && clamped.y === prev.y ? prev : clamped;
|
|
153
145
|
});
|
|
154
146
|
};
|
|
155
147
|
|
|
@@ -157,23 +149,19 @@ export const usePosition = (isOpen: boolean, isFullscreen: boolean) => {
|
|
|
157
149
|
return () => window.removeEventListener("resize", handleResize);
|
|
158
150
|
}, [clampToBounds]);
|
|
159
151
|
|
|
160
|
-
// 修复:只在 isOpen 状态改变时检查边界,避免无限循环
|
|
161
152
|
useEffect(() => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (!initializedRef.current) {
|
|
169
|
-
const clamped = clampToBounds(position);
|
|
170
|
-
if (clamped.x !== position.x || clamped.y !== position.y) {
|
|
171
|
-
setPosition(clamped);
|
|
153
|
+
setPosition((prev) => {
|
|
154
|
+
if (prev.x < 0 || prev.y < 0) {
|
|
155
|
+
return prev;
|
|
156
|
+
}
|
|
157
|
+
const clamped = clampToBounds(prev);
|
|
158
|
+
if (clamped.x !== prev.x || clamped.y !== prev.y) {
|
|
172
159
|
savePosition(clamped);
|
|
160
|
+
return clamped;
|
|
173
161
|
}
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
}, [isOpen, clampToBounds]);
|
|
162
|
+
return prev;
|
|
163
|
+
});
|
|
164
|
+
}, [isOpen, clampToBounds]);
|
|
177
165
|
|
|
178
166
|
const setPositionTo = useCallback(
|
|
179
167
|
(x: "left" | "right", y: "top" | "bottom") => {
|
|
@@ -191,7 +179,7 @@ export const usePosition = (isOpen: boolean, isFullscreen: boolean) => {
|
|
|
191
179
|
setPosition(newPos);
|
|
192
180
|
savePosition(newPos);
|
|
193
181
|
},
|
|
194
|
-
[],
|
|
182
|
+
[getElementDimensions],
|
|
195
183
|
);
|
|
196
184
|
return {
|
|
197
185
|
position,
|
|
@@ -15,6 +15,8 @@ const i18nText = {
|
|
|
15
15
|
online: "在线",
|
|
16
16
|
thinking: "思考中",
|
|
17
17
|
thought: "思考过程",
|
|
18
|
+
thoughtCountUnit: "条思考",
|
|
19
|
+
toolCountUnit: "次工具",
|
|
18
20
|
newConversation: "开始新对话",
|
|
19
21
|
fullscreen: "全屏显示",
|
|
20
22
|
exitFullscreen: "退出全屏",
|
|
@@ -30,6 +32,8 @@ const i18nText = {
|
|
|
30
32
|
sideLeft: "侧边模式(左)",
|
|
31
33
|
sideRight: "侧边模式(右)",
|
|
32
34
|
fullscreen: "全屏模式",
|
|
35
|
+
halfTop: "上半屏",
|
|
36
|
+
halfBottom: "下半屏",
|
|
33
37
|
},
|
|
34
38
|
voiceInput: "语音输入",
|
|
35
39
|
stopVoiceInput: "停止录音",
|
|
@@ -72,6 +76,8 @@ const i18nText = {
|
|
|
72
76
|
online: "Online",
|
|
73
77
|
thinking: "Thinking",
|
|
74
78
|
thought: "Thought process",
|
|
79
|
+
thoughtCountUnit: "thoughts",
|
|
80
|
+
toolCountUnit: "tools",
|
|
75
81
|
newConversation: "Start a new chat",
|
|
76
82
|
fullscreen: "Fullscreen",
|
|
77
83
|
exitFullscreen: "Exit fullscreen",
|
|
@@ -88,6 +94,8 @@ const i18nText = {
|
|
|
88
94
|
sideLeft: "Side mode (left)",
|
|
89
95
|
sideRight: "Side mode (right)",
|
|
90
96
|
fullscreen: "Fullscreen mode",
|
|
97
|
+
halfTop: "Top half",
|
|
98
|
+
halfBottom: "Bottom half",
|
|
91
99
|
},
|
|
92
100
|
voiceInput: "Voice input",
|
|
93
101
|
stopVoiceInput: "Stop recording",
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taro-project",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "开箱即用的基于Taro + React + Zustand + TailwindCSS + TypeScript的模板",
|
|
5
5
|
"author": "amaster.ai",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"packageManager": "bun@1.2.21",
|
|
7
8
|
"templateInfo": {
|
|
8
9
|
"name": "default",
|
|
9
10
|
"typescript": true,
|
|
10
11
|
"css": "sass"
|
|
11
12
|
},
|
|
12
13
|
"scripts": {
|
|
13
|
-
"dev": "bun run
|
|
14
|
+
"dev": "bun run preview:h5",
|
|
15
|
+
"preview:h5": "taro build --type h5 --watch",
|
|
16
|
+
"dev:h5": "bun run preview:h5",
|
|
17
|
+
"dev:weapp": "taro build --type weapp --watch",
|
|
18
|
+
"build": "bun run build:weapp",
|
|
19
|
+
"build:mini": "bun run build:weapp",
|
|
14
20
|
"build:weapp": "taro build --type weapp",
|
|
15
21
|
"build:swan": "taro build --type swan",
|
|
16
22
|
"build:alipay": "taro build --type alipay",
|
|
@@ -24,8 +30,9 @@
|
|
|
24
30
|
"lint:check": "biome check .",
|
|
25
31
|
"lint:fix": "biome check --write --unsafe .",
|
|
26
32
|
"lint:format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,scss,md}'",
|
|
27
|
-
"
|
|
28
|
-
"check
|
|
33
|
+
"pre-commit-check": "amaster-cli check all --stack taro",
|
|
34
|
+
"type-check": "amaster-cli check type --stack taro",
|
|
35
|
+
"check:build": "amaster-cli check build --stack taro",
|
|
29
36
|
"precommit": "bun run lint:check && bun run type-check",
|
|
30
37
|
"prepare": "husky || true",
|
|
31
38
|
"postinstall": "weapp-tw patch || true",
|
|
@@ -39,10 +46,10 @@
|
|
|
39
46
|
],
|
|
40
47
|
"dependencies": {
|
|
41
48
|
"@a2a-js/sdk": "^0.3.7",
|
|
42
|
-
"@amaster.ai/bpm-ui": "1.1.0-beta.
|
|
43
|
-
"@amaster.ai/client": "1.1.0-beta.
|
|
44
|
-
"@amaster.ai/taro-echarts-ui": "1.1.0-beta.
|
|
45
|
-
"@amaster.ai/vite-plugins": "1.1.0-beta.
|
|
49
|
+
"@amaster.ai/bpm-ui": "1.1.0-beta.67",
|
|
50
|
+
"@amaster.ai/client": "1.1.0-beta.67",
|
|
51
|
+
"@amaster.ai/taro-echarts-ui": "1.1.0-beta.67",
|
|
52
|
+
"@amaster.ai/vite-plugins": "1.1.0-beta.67",
|
|
46
53
|
"@babel/runtime": "^7.28.3",
|
|
47
54
|
"@tarojs/components": "4.1.5",
|
|
48
55
|
"@tarojs/helper": "4.1.5",
|
|
@@ -69,6 +76,7 @@
|
|
|
69
76
|
"@egoist/tailwindcss-icons": "^1.9.0",
|
|
70
77
|
"@iconify-json/lucide": "^1.2.64",
|
|
71
78
|
"@iconify-json/mdi": "^1.2.3",
|
|
79
|
+
"@amaster.ai/cli": "1.1.0-beta.67",
|
|
72
80
|
"@tarojs/cli": "4.1.5",
|
|
73
81
|
"@tarojs/taro-loader": "4.1.5",
|
|
74
82
|
"@tarojs/vite-runner": "4.1.5",
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { View } from "@tarojs/components";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { JsonRenderer } from "@/components/json-render";
|
|
3
4
|
import { useAiAssistantI18n } from "../i18n";
|
|
4
5
|
import type {
|
|
5
6
|
MessagesItem,
|
|
6
7
|
TextMessage,
|
|
7
8
|
ThoughtMessage,
|
|
8
9
|
ToolMessage,
|
|
10
|
+
UIRenderMessage,
|
|
9
11
|
} from "../types";
|
|
10
12
|
import Markdown from "./markdown";
|
|
11
13
|
|
|
@@ -51,7 +53,7 @@ const ChatThoughtMessage: React.FC<
|
|
|
51
53
|
const { t } = useAiAssistantI18n();
|
|
52
54
|
const thinking = isLoading && isNewest;
|
|
53
55
|
return (
|
|
54
|
-
<View className="leading-relaxed whitespace-pre-wrap break-words text-
|
|
56
|
+
<View className="leading-relaxed whitespace-pre-wrap break-words text-sm overflow-hidden text-left">
|
|
55
57
|
<View
|
|
56
58
|
className="hover:border-border border bg-primary/50 text-primary-foreground px-2 py-1 rounded-xl inline-flex items-center gap-1 cursor-pointer"
|
|
57
59
|
onClick={() => setExpanded(!expanded)}
|
|
@@ -81,7 +83,7 @@ const ChatToolMessage: React.FC<
|
|
|
81
83
|
const status = message.toolStatus || "executing";
|
|
82
84
|
const { t } = useAiAssistantI18n();
|
|
83
85
|
return (
|
|
84
|
-
<View className="leading-relaxed whitespace-pre-wrap break-words flex items-center gap-1 border px-2 p-1 rounded-xl bg-
|
|
86
|
+
<View className="leading-relaxed whitespace-pre-wrap break-words flex items-center gap-1 border px-2 p-1 rounded-xl bg-primary/50 text-primary-foreground text-sm max-w-full overflow-hidden">
|
|
85
87
|
{status === "success" ? (
|
|
86
88
|
<View className="i-lucide-badge-check size-4 text-green-600 fill-current shrink-0" />
|
|
87
89
|
) : status === "failed" || status === "error" ? (
|
|
@@ -109,6 +111,24 @@ const ChatErrorMessage: React.FC<
|
|
|
109
111
|
);
|
|
110
112
|
};
|
|
111
113
|
|
|
114
|
+
const ChatUIRenderMessage: React.FC<{ message: UIRenderMessage }> = ({
|
|
115
|
+
message,
|
|
116
|
+
}) => {
|
|
117
|
+
if (!message.spec) {
|
|
118
|
+
return (
|
|
119
|
+
<View className="p-2 border border-destructive rounded-lg bg-destructive/10">
|
|
120
|
+
<View className="text-destructive">Invalid UI spec</View>
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<View className="w-full overflow-hidden">
|
|
127
|
+
<JsonRenderer spec={message.spec} />
|
|
128
|
+
</View>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
112
132
|
const MessageContentRenderer: React.FC<
|
|
113
133
|
{
|
|
114
134
|
message: MessagesItem;
|
|
@@ -125,6 +145,8 @@ const MessageContentRenderer: React.FC<
|
|
|
125
145
|
return <ChatToolMessage message={message as ToolMessage} {...rest} />;
|
|
126
146
|
case "error":
|
|
127
147
|
return <ChatErrorMessage message={message as TextMessage} {...rest} />;
|
|
148
|
+
case "ui-render":
|
|
149
|
+
return <ChatUIRenderMessage message={message as UIRenderMessage} />;
|
|
128
150
|
default:
|
|
129
151
|
return null;
|
|
130
152
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Text, Textarea, View } from "@tarojs/components";
|
|
2
2
|
import type React from "react";
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
3
|
+
import { useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useSafeArea } from "../hooks/useSafeArea";
|
|
5
5
|
import { useAiAssistantI18n } from "../i18n";
|
|
6
6
|
import type { Conversation } from "../types";
|
|
7
|
+
import { RecommendedQuestions } from "./RecommendedQuestions";
|
|
7
8
|
import { VoiceInput } from "./VoiceInput";
|
|
8
9
|
|
|
9
10
|
interface ChatInputProps {
|
|
@@ -58,7 +59,7 @@ const StopButton: React.FC<{ onClick: () => void }> = ({ onClick }) => {
|
|
|
58
59
|
|
|
59
60
|
const NewConversationButton: React.FC<{
|
|
60
61
|
disabled: boolean;
|
|
61
|
-
onClick
|
|
62
|
+
onClick?: () => void;
|
|
62
63
|
}> = ({ disabled, onClick }) => {
|
|
63
64
|
const { t } = useAiAssistantI18n();
|
|
64
65
|
return (
|
|
@@ -103,6 +104,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
103
104
|
}, [lastConv]);
|
|
104
105
|
const disabledNewConversation =
|
|
105
106
|
!hasConversations || lastIsDivider || starting || isLoading;
|
|
107
|
+
const textareaRef = useRef<any>(null);
|
|
106
108
|
|
|
107
109
|
const handleInput = (e: { detail: { value: string } }) => {
|
|
108
110
|
onInputChange(e.detail.value);
|
|
@@ -121,68 +123,88 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
121
123
|
} else {
|
|
122
124
|
onInputChange(inputValue + text);
|
|
123
125
|
}
|
|
126
|
+
|
|
127
|
+
// 2. 延迟执行滚动(等渲染完成)
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
scrollToEndQuickly();
|
|
130
|
+
}, 60); // 50~100ms,根据真机测试微调
|
|
124
131
|
}
|
|
125
132
|
};
|
|
126
133
|
|
|
134
|
+
const scrollToEndQuickly = () => {
|
|
135
|
+
const ta = textareaRef.current;
|
|
136
|
+
if (!ta) return;
|
|
137
|
+
|
|
138
|
+
const len = inputValue.length;
|
|
139
|
+
|
|
140
|
+
ta.focus?.();
|
|
141
|
+
ta.setSelectionRange?.(len, len);
|
|
142
|
+
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
ta.blur?.();
|
|
145
|
+
}, 30);
|
|
146
|
+
};
|
|
147
|
+
|
|
127
148
|
return (
|
|
128
149
|
<View
|
|
129
|
-
className="px-4 py-3
|
|
150
|
+
className="px-4 py-3"
|
|
130
151
|
style={{ paddingBottom: `${8 + safeAreaInsets.bottom}px` }}
|
|
131
152
|
>
|
|
132
153
|
{!isLoading && recommendedQuestions.length > 0 && (
|
|
133
154
|
<View className="mb-3">
|
|
134
|
-
<
|
|
135
|
-
{recommendedQuestions
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
onClick={() => onQuestionClick?.(question)}
|
|
139
|
-
className="text-xs px-3 py-1.5 bg-primary/20 text-primary rounded-full border border-primary/40 active:bg-primary/30"
|
|
140
|
-
hoverClass="bg-primary/40"
|
|
141
|
-
>
|
|
142
|
-
<Text>{question}</Text>
|
|
143
|
-
</View>
|
|
144
|
-
))}
|
|
145
|
-
</View>
|
|
155
|
+
<RecommendedQuestions
|
|
156
|
+
questions={recommendedQuestions}
|
|
157
|
+
onQuestionClick={onQuestionClick}
|
|
158
|
+
/>
|
|
146
159
|
</View>
|
|
147
160
|
)}
|
|
148
161
|
|
|
149
162
|
<View
|
|
150
|
-
className={`w-full rounded-xl bg-
|
|
163
|
+
className={`w-full rounded-xl bg-background transition-all duration-200 p-3 border ${
|
|
151
164
|
isFocused ? "border-primary shadow-sm" : "border-primary/40"
|
|
152
165
|
}`}
|
|
153
166
|
data-role="chat-input"
|
|
154
167
|
>
|
|
155
168
|
<Textarea
|
|
169
|
+
ref={textareaRef}
|
|
156
170
|
value={inputValue}
|
|
157
171
|
onInput={handleInput}
|
|
158
172
|
onFocus={() => setIsFocused(true)}
|
|
159
173
|
onBlur={() => setIsFocused(false)}
|
|
160
174
|
placeholder={t.inputPlaceholder}
|
|
161
175
|
disabled={isLoading}
|
|
162
|
-
maxlength={
|
|
163
|
-
|
|
164
|
-
fixed
|
|
165
|
-
style={{ minHeight: "48px", maxHeight: "120px" }}
|
|
166
|
-
className="w-full text-base text-foreground placeholder:text-muted-foreground leading-relaxed disabled:opacity-50"
|
|
176
|
+
maxlength={10000}
|
|
177
|
+
className="w-full text-base text-foreground placeholder:text-muted-foreground leading-relaxed disabled:opacity-50 resize-none"
|
|
167
178
|
data-role="chat-textarea"
|
|
168
|
-
|
|
179
|
+
confirm-type="send"
|
|
180
|
+
style={{
|
|
181
|
+
height: "60px",
|
|
182
|
+
minHeight: "60px",
|
|
183
|
+
maxHeight: "60px",
|
|
184
|
+
resize: "none",
|
|
185
|
+
background: "none",
|
|
186
|
+
fontSize: "14px",
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{/* 不要删掉,这是为了解决 h5 textarea 样式问题 */}
|
|
190
|
+
<style>
|
|
191
|
+
{`.taro-textarea {background: none;resize: none;}`}
|
|
192
|
+
</style>
|
|
193
|
+
</Textarea>
|
|
169
194
|
|
|
170
195
|
<View
|
|
171
|
-
className="flex items-center justify-between mt-
|
|
196
|
+
className="flex items-center justify-between mt-1 pt-2"
|
|
172
197
|
data-role="chat-tools"
|
|
173
198
|
>
|
|
174
199
|
<View className="flex items-center gap-2">
|
|
175
200
|
<NewConversationButton
|
|
176
201
|
disabled={disabledNewConversation}
|
|
177
|
-
onClick={onNewConversation
|
|
202
|
+
onClick={onNewConversation}
|
|
178
203
|
/>
|
|
179
204
|
</View>
|
|
180
205
|
|
|
181
206
|
<View className="flex items-center gap-2">
|
|
182
|
-
<VoiceInput
|
|
183
|
-
onResult={handleVoiceResult}
|
|
184
|
-
disabled={starting}
|
|
185
|
-
/>
|
|
207
|
+
<VoiceInput onResult={handleVoiceResult} disabled={starting} />
|
|
186
208
|
{isLoading ? (
|
|
187
209
|
<StopButton onClick={onCancel} />
|
|
188
210
|
) : (
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ScrollView, Text, View } from "@tarojs/components";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
|
|
4
|
+
interface RecommendedQuestionsProps {
|
|
5
|
+
questions: string[];
|
|
6
|
+
onQuestionClick?: (question: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const RecommendedQuestions: React.FC<RecommendedQuestionsProps> = ({
|
|
10
|
+
questions,
|
|
11
|
+
onQuestionClick,
|
|
12
|
+
}) => {
|
|
13
|
+
if (questions.length === 0) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<ScrollView
|
|
19
|
+
scrollX
|
|
20
|
+
enhanced
|
|
21
|
+
showScrollbar={false}
|
|
22
|
+
enableFlex
|
|
23
|
+
className="w-full"
|
|
24
|
+
>
|
|
25
|
+
<View className="flex w-max gap-2 pr-4">
|
|
26
|
+
{questions.map((question) => (
|
|
27
|
+
<View
|
|
28
|
+
key={question}
|
|
29
|
+
onClick={() => onQuestionClick?.(question)}
|
|
30
|
+
className="shrink-0 text-xs px-3 py-1.5 bg-primary/20 text-primary rounded-full border border-primary/40 active:bg-primary/30"
|
|
31
|
+
hoverClass="bg-primary/40"
|
|
32
|
+
>
|
|
33
|
+
<Text className="whitespace-nowrap">{question}</Text>
|
|
34
|
+
</View>
|
|
35
|
+
))}
|
|
36
|
+
</View>
|
|
37
|
+
</ScrollView>
|
|
38
|
+
);
|
|
39
|
+
};
|