@tbox.cn/app-template-agent 0.7.5 → 0.8.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.
Files changed (152) hide show
  1. package/.gitignore +5 -0
  2. package/.tbox/app.json +9 -1
  3. package/AGENTS.md +12 -4
  4. package/ENV.md +102 -17
  5. package/README.md +36 -34
  6. package/apps/client/VISUAL_RESTORE.md +11 -11
  7. package/apps/client/index.html +3 -1
  8. package/apps/client/package.json +9 -8
  9. package/apps/client/src/business/App.tsx +4 -4
  10. package/apps/client/src/business/README.md +5 -11
  11. package/apps/client/src/cards/registry.ts +23 -8
  12. package/apps/client/src/main.tsx +36 -2
  13. package/apps/client/src/modules.ts +7 -4
  14. package/apps/client/src/pages/index/index.tsx +18 -162
  15. package/apps/client/src/platform/AgentChatApp.tsx +3 -3
  16. package/apps/client/src/platform/agent-runtime.ts +5 -0
  17. package/apps/client/src/store/chat.ts +47 -185
  18. package/apps/client/src/test/setup.ts +7 -0
  19. package/apps/client/vite.config.ts +16 -1
  20. package/apps/server/package.json +0 -1
  21. package/apps/server/src/app-tools.ts +2 -1
  22. package/apps/server/src/app.ts +178 -110
  23. package/apps/server/src/config/integration.ts +2 -6
  24. package/apps/server/src/dev.ts +0 -1
  25. package/apps/server/src/intent-routes.ts +3 -3
  26. package/apps/server/src/modules.ts +4 -4
  27. package/apps/server/src/pre-tools.ts +4 -3
  28. package/apps/server/tests/agent-prompt.test.ts +81 -0
  29. package/apps/server/tests/cards-registry.test.ts +1 -3
  30. package/apps/server/tests/deployment-loader.test.ts +35 -56
  31. package/apps/server/tests/tool-runtime.test.ts +25 -61
  32. package/package.json +5 -4
  33. package/packages/agent-ui-kit/LICENSE +21 -0
  34. package/packages/agent-ui-kit/README.md +17 -0
  35. package/packages/agent-ui-kit/package.json +58 -0
  36. package/packages/agent-ui-kit/src/ChatPage.less +62 -0
  37. package/packages/agent-ui-kit/src/ChatPage.tsx +305 -0
  38. package/packages/agent-ui-kit/src/chat-config.test.ts +82 -0
  39. package/packages/agent-ui-kit/src/chat-config.ts +62 -0
  40. package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/DebugButton.tsx +2 -2
  41. package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.less +19 -16
  42. package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.tsx +10 -13
  43. package/packages/agent-ui-kit/src/components/CardRenderer.tsx +36 -0
  44. package/packages/agent-ui-kit/src/components/ChatHeader/index.less +148 -0
  45. package/packages/agent-ui-kit/src/components/ChatHeader/index.test.tsx +74 -0
  46. package/{apps/client → packages/agent-ui-kit}/src/components/ChatHeader/index.tsx +27 -15
  47. package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.less +47 -39
  48. package/packages/agent-ui-kit/src/components/ChatInput/index.test.tsx +156 -0
  49. package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.tsx +13 -4
  50. package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/useVoicePress.ts +2 -2
  51. package/packages/agent-ui-kit/src/components/HistoryEntry/index.less +49 -0
  52. package/packages/agent-ui-kit/src/components/HistoryEntry/index.tsx +42 -0
  53. package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.less +3 -3
  54. package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.tsx +6 -6
  55. package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.less +25 -20
  56. package/packages/agent-ui-kit/src/components/MessageBubble/index.test.tsx +112 -0
  57. package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.tsx +22 -15
  58. package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.less +2 -2
  59. package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.tsx +2 -2
  60. package/packages/agent-ui-kit/src/components/QuickCommands/index.less +63 -0
  61. package/packages/agent-ui-kit/src/components/QuickCommands/index.test.tsx +99 -0
  62. package/packages/agent-ui-kit/src/components/QuickCommands/index.tsx +52 -0
  63. package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.less +1 -1
  64. package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.test.ts +346 -0
  65. package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.ts +204 -0
  66. package/packages/agent-ui-kit/src/components/ToolCallBlock/index.less +312 -0
  67. package/packages/agent-ui-kit/src/components/ToolCallBlock/index.test.tsx +261 -0
  68. package/packages/agent-ui-kit/src/components/ToolCallBlock/index.tsx +212 -0
  69. package/packages/agent-ui-kit/src/components/ToolCallBlock/titles.ts +102 -0
  70. package/packages/agent-ui-kit/src/hooks/use-drag-gesture.test.ts +66 -0
  71. package/packages/agent-ui-kit/src/hooks/use-drag-gesture.ts +129 -0
  72. package/packages/agent-ui-kit/src/hooks/use-pull-down-history.ts +241 -0
  73. package/packages/agent-ui-kit/src/hooks/use-pull-down-wheel-native.test.ts +64 -0
  74. package/{apps/client → packages/agent-ui-kit}/src/hooks/useMediaUpload.ts +3 -3
  75. package/{apps/client → packages/agent-ui-kit}/src/hooks/useTTSAutoPlay.ts +7 -5
  76. package/packages/agent-ui-kit/src/index.ts +69 -0
  77. package/packages/agent-ui-kit/src/store-context.tsx +92 -0
  78. package/packages/agent-ui-kit/src/test/setup.ts +7 -0
  79. package/packages/agent-ui-kit/src/theme/contract.json +72 -0
  80. package/packages/agent-ui-kit/src/theme/global.less +99 -0
  81. package/packages/agent-ui-kit/src/theme/motion.less +19 -0
  82. package/packages/agent-ui-kit/src/theme/theme-contract.test.ts +69 -0
  83. package/packages/agent-ui-kit/src/theme/theme.ts +17 -0
  84. package/packages/agent-ui-kit/src/theme/tokens.less +79 -0
  85. package/packages/agent-ui-kit/src/utils/haptics.test.ts +51 -0
  86. package/packages/agent-ui-kit/src/utils/haptics.ts +44 -0
  87. package/packages/agent-ui-kit/src/utils/pull-down-machine.test.ts +188 -0
  88. package/packages/agent-ui-kit/src/utils/pull-down-machine.ts +157 -0
  89. package/packages/agent-ui-kit/src/utils/tool-call-display.test.ts +240 -0
  90. package/packages/agent-ui-kit/src/utils/tool-call-display.ts +146 -0
  91. package/packages/agent-ui-kit/src/vite-env.d.ts +1 -0
  92. package/packages/agent-ui-kit/tsconfig.json +8 -0
  93. package/packages/agent-ui-kit/vitest.config.ts +7 -0
  94. package/pnpm-lock.template.yaml +177 -83
  95. package/pnpm-workspace.template.yaml +12 -4
  96. package/profiles/mall/config/agent.jsonc +33 -0
  97. package/profiles/mall/config/chat.json +21 -0
  98. package/profiles/mall/config/knowledge.json +5 -0
  99. package/profiles/mall/config/llm_provider.json +5 -0
  100. package/profiles/mall/config/plugins.json +3 -0
  101. package/profiles/mall.json +16 -0
  102. package/scripts/check-workspace.mjs +3 -2
  103. package/apps/client/assets/copy@2x.png +0 -0
  104. package/apps/client/assets/debug.png +0 -0
  105. package/apps/client/assets/dislike@2x.png +0 -0
  106. package/apps/client/assets/like@2x.png +0 -0
  107. package/apps/client/assets/speaker@2x.png +0 -0
  108. package/apps/client/src/cards/CardRenderer.tsx +0 -20
  109. package/apps/client/src/components/ChatHeader/index.less +0 -104
  110. package/apps/client/src/components/ErrorBoundary.tsx +0 -2
  111. package/apps/client/src/components/QuickCommands/index.less +0 -48
  112. package/apps/client/src/components/QuickCommands/index.tsx +0 -29
  113. package/apps/client/src/components/ToolCallBlock/index.less +0 -131
  114. package/apps/client/src/components/ToolCallBlock/index.tsx +0 -125
  115. package/apps/client/src/examples/tourism/TourismLanding.tsx +0 -96
  116. package/apps/client/src/examples/tourism/index.less +0 -192
  117. package/apps/client/src/pages/index/index.less +0 -43
  118. package/apps/client/src/store/chat-config.ts +0 -40
  119. package/apps/client/src/styles/global.less +0 -60
  120. package/apps/client/src/styles/tokens.less +0 -60
  121. package/apps/server/src/config/deployment.ts +0 -47
  122. package/packages/module-weather/package.json +0 -36
  123. package/packages/module-weather/skills/weather-guide/SKILL.md +0 -22
  124. package/packages/module-weather/skills/weather-guide/references/dressing-tips.md +0 -5
  125. package/packages/module-weather/src/client/cards/weather/index.less +0 -22
  126. package/packages/module-weather/src/client/cards/weather/index.tsx +0 -14
  127. package/packages/module-weather/src/client/index.ts +0 -19
  128. package/packages/module-weather/src/index.ts +0 -2
  129. package/packages/module-weather/src/server/cards/weather/meta.ts +0 -27
  130. package/packages/module-weather/src/server/index.ts +0 -45
  131. package/packages/module-weather/src/server/pre-tools.ts +0 -20
  132. package/packages/module-weather/src/server/tool.ts +0 -29
  133. package/packages/module-weather/tbox.module.json +0 -27
  134. package/packages/module-weather/tsconfig.json +0 -14
  135. /package/{apps/client → packages/agent-ui-kit}/assets/keyboard-circle.svg +0 -0
  136. /package/{apps/client → packages/agent-ui-kit}/assets/more-circle.svg +0 -0
  137. /package/{apps/client → packages/agent-ui-kit}/assets/new-chat.svg +0 -0
  138. /package/{apps/client → packages/agent-ui-kit}/assets/quick-command-star.svg +0 -0
  139. /package/{apps/client → packages/agent-ui-kit}/assets/send-gradient.svg +0 -0
  140. /package/{apps/client → packages/agent-ui-kit}/assets/stop@2x.png +0 -0
  141. /package/{apps/client → packages/agent-ui-kit}/assets/voice-circle.svg +0 -0
  142. /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoicePressField.tsx +0 -0
  143. /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoiceRecordingOverlay.tsx +0 -0
  144. /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/machine.ts +0 -0
  145. /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.less +0 -0
  146. /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.tsx +0 -0
  147. /package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.tsx +0 -0
  148. /package/{apps/client/src/types/chat.ts → packages/agent-ui-kit/src/types.ts} +0 -0
  149. /package/{apps/client → packages/agent-ui-kit}/src/utils/copyText.ts +0 -0
  150. /package/{apps/client → packages/agent-ui-kit}/src/utils/imagePreview.ts +0 -0
  151. /package/{apps/client → packages/agent-ui-kit}/src/utils/session-error.ts +0 -0
  152. /package/{apps/client → packages/agent-ui-kit}/src/utils/toast.ts +0 -0
@@ -0,0 +1,148 @@
1
+ /* 顶部品牌区 + 右对齐控件条带(014 T7:mall 形态——品牌名来自 HELLO_ACK.mallScope.mallName)。 */
2
+ .chat-header {
3
+ position: absolute;
4
+ top: calc(10px + env(safe-area-inset-top, 0px));
5
+ left: 50%;
6
+ z-index: 100;
7
+ display: flex;
8
+ width: 100%;
9
+ max-width: var(--tbox-content-max-width);
10
+ padding: 0 32px;
11
+ flex-direction: column;
12
+ align-items: flex-end;
13
+ pointer-events: none;
14
+ transform: translateX(-50%);
15
+
16
+ &__brand {
17
+ position: absolute;
18
+ top: 4px;
19
+ left: 32px;
20
+ display: inline-flex;
21
+ align-items: center;
22
+ min-height: 44px;
23
+ color: var(--tbox-text-primary);
24
+ }
25
+
26
+ &__brand-mark {
27
+ width: 7px;
28
+ height: 34px;
29
+ margin-right: 14px;
30
+ background: var(--tbox-primary);
31
+ border-radius: 2px;
32
+ }
33
+
34
+ &__brand-name {
35
+ font-size: 24px;
36
+ font-weight: 760;
37
+ line-height: 1;
38
+ letter-spacing: 0;
39
+ }
40
+
41
+ &__group {
42
+ display: flex;
43
+ align-items: center;
44
+ max-height: 80px;
45
+ margin-bottom: 6px;
46
+ padding: 6px;
47
+ gap: 4px;
48
+ overflow: hidden;
49
+ opacity: 1;
50
+ pointer-events: auto;
51
+ background: rgba(255, 255, 255, 0.96);
52
+ border: 1px solid var(--tbox-border);
53
+ border-radius: 12px;
54
+ box-shadow: var(--tbox-shadow-sm);
55
+ transform: translateY(0);
56
+ transition: opacity var(--tbox-transition-base), transform var(--tbox-transition-base);
57
+
58
+ &--collapsed {
59
+ max-height: 0;
60
+ margin-bottom: 0;
61
+ padding-top: 0;
62
+ padding-bottom: 0;
63
+ opacity: 0;
64
+ pointer-events: none;
65
+ transform: translateY(-8px);
66
+ }
67
+ }
68
+
69
+ &__btn {
70
+ display: inline-flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ min-height: 52px;
74
+ padding: 0 15px;
75
+ gap: 7px;
76
+ color: var(--tbox-text-secondary);
77
+ appearance: none;
78
+ background: transparent;
79
+ border: 0;
80
+ border-radius: 8px;
81
+
82
+ &:active {
83
+ background: var(--tbox-bg-subtle);
84
+ }
85
+
86
+ &:focus-visible {
87
+ outline: 3px solid rgba(9, 107, 250, 0.24);
88
+ outline-offset: -2px;
89
+ }
90
+ }
91
+
92
+ &__btn-icon {
93
+ width: 22px;
94
+ height: 22px;
95
+ color: var(--tbox-primary);
96
+ flex-shrink: 0;
97
+ }
98
+
99
+ &__btn-text {
100
+ color: inherit;
101
+ font-size: 22px;
102
+ font-weight: 560;
103
+ line-height: 1;
104
+ white-space: nowrap;
105
+ }
106
+
107
+ &__toggle {
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ width: 56px;
112
+ height: 44px;
113
+ color: var(--tbox-text-muted);
114
+ appearance: none;
115
+ pointer-events: auto;
116
+ background: rgba(255, 255, 255, 0.92);
117
+ border: 1px solid var(--tbox-border);
118
+ border-radius: 8px;
119
+ box-shadow: var(--tbox-shadow-sm);
120
+
121
+ &:active {
122
+ background: var(--tbox-bg-subtle);
123
+ }
124
+
125
+ &:focus-visible {
126
+ outline: 3px solid rgba(9, 107, 250, 0.24);
127
+ outline-offset: 2px;
128
+ }
129
+ }
130
+
131
+ &__arrow {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ transition: transform var(--tbox-transition-base);
136
+
137
+ &--up {
138
+ transform: rotate(180deg);
139
+ }
140
+ }
141
+ }
142
+
143
+ @media (prefers-reduced-motion: reduce) {
144
+ .chat-header__group,
145
+ .chat-header__arrow {
146
+ transition: none;
147
+ }
148
+ }
@@ -0,0 +1,74 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { flushSync } from 'react-dom';
5
+ import ChatHeader from './index';
6
+
7
+ interface MockChatState {
8
+ helloAck: { mallScope?: Record<string, string> } | null;
9
+ }
10
+
11
+ let mockHelloAck: MockChatState['helloAck'] = null;
12
+
13
+ vi.mock('../../store-context', () => ({
14
+ useChatStore: (selector: (s: MockChatState) => unknown) =>
15
+ selector({ helloAck: mockHelloAck }),
16
+ }));
17
+
18
+ vi.mock('../../chat-config', () => ({
19
+ useChatConfigStore: (selector: (s: { enableTTS: boolean }) => unknown) =>
20
+ selector({ enableTTS: false }),
21
+ }));
22
+
23
+ function renderHeader(container: HTMLDivElement) {
24
+ flushSync(() => {
25
+ createRoot(container).render(
26
+ <ChatHeader ttsAutoPlay={false} onToggleTTS={() => {}} onClearChat={() => {}} />,
27
+ );
28
+ });
29
+ }
30
+
31
+ describe('ChatHeader 品牌区(014 T7:mallScope.mallName 驱动,非 mall 部署降级)', () => {
32
+ beforeEach(() => {
33
+ mockHelloAck = null;
34
+ });
35
+
36
+ it('有 mallScope.mallName → 渲染品牌区(mark + mallName)', () => {
37
+ mockHelloAck = { mallScope: { mallId: 'mall-demo', mallName: '示例商场' } };
38
+ const container = document.createElement('div');
39
+ document.body.appendChild(container);
40
+ try {
41
+ renderHeader(container);
42
+ const brand = container.querySelector('.chat-header__brand');
43
+ expect(brand).not.toBeNull();
44
+ expect(brand?.textContent).toContain('示例商场');
45
+ expect(brand?.getAttribute('aria-label')).toBe('示例商场');
46
+ } finally {
47
+ document.body.removeChild(container);
48
+ }
49
+ });
50
+
51
+ it('无 mallScope(非 mall 部署)→ 不渲染品牌区', () => {
52
+ mockHelloAck = null;
53
+ const container = document.createElement('div');
54
+ document.body.appendChild(container);
55
+ try {
56
+ renderHeader(container);
57
+ expect(container.querySelector('.chat-header__brand')).toBeNull();
58
+ } finally {
59
+ document.body.removeChild(container);
60
+ }
61
+ });
62
+
63
+ it('mallScope 缺 mallName → 不渲染品牌区(回显不完整降级)', () => {
64
+ mockHelloAck = { mallScope: { mallId: 'mall-demo' } };
65
+ const container = document.createElement('div');
66
+ document.body.appendChild(container);
67
+ try {
68
+ renderHeader(container);
69
+ expect(container.querySelector('.chat-header__brand')).toBeNull();
70
+ } finally {
71
+ document.body.removeChild(container);
72
+ }
73
+ });
74
+ });
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import { CaretDown } from '@phosphor-icons/react';
3
- import { useChatConfigStore } from '@/store/chat-config';
2
+ import { CaretDown, SpeakerHigh, SpeakerSlash, Trash } from '@phosphor-icons/react';
3
+ import { useChatConfigStore } from '../../chat-config';
4
+ import { useChatStore } from '../../store-context';
4
5
  import './index.less';
5
6
 
6
7
  interface ChatHeaderProps {
@@ -9,17 +10,12 @@ interface ChatHeaderProps {
9
10
  onClearChat: () => void;
10
11
  }
11
12
 
12
- const ICON_CLEAR =
13
- 'https://mdn.alipayobjects.com/huamei_frp8ut/afts/img/A*0hnoTakSA0YAAAAAMsAAAAgAet-KAQ/original';
14
- const ICON_AUDIO_ON =
15
- 'https://mdn.alipayobjects.com/huamei_frp8ut/afts/img/A*as7eQZz3DskAAAAAQBAAAAgAet-KAQ/original';
16
- const ICON_AUDIO_OFF =
17
- 'https://mdn.alipayobjects.com/huamei_frp8ut/afts/img/A*ZmoCQK01340AAAAAQCAAAAgAet-KAQ/original';
18
-
19
13
  export default function ChatHeader({ ttsAutoPlay, onToggleTTS, onClearChat }: ChatHeaderProps) {
20
14
  const [isExpanded, setIsExpanded] = useState(false);
21
15
  const headerRef = useRef<HTMLDivElement>(null);
22
16
  const enableTTS = useChatConfigStore((s) => s.enableTTS);
17
+ // 014 T7:mall 品牌名 = 服务端 HELLO_ACK.mallScope.mallName(模板 buildScope 回显);非 mall 部署无 mallScope → 不渲染品牌区
18
+ const mallName = useChatStore((s) => s.helloAck?.mallScope?.mallName);
23
19
 
24
20
  const handleClear = useCallback(() => {
25
21
  onClearChat();
@@ -41,21 +37,37 @@ export default function ChatHeader({ ttsAutoPlay, onToggleTTS, onClearChat }: Ch
41
37
 
42
38
  return (
43
39
  <div className="chat-header" ref={headerRef}>
40
+ {mallName ? (
41
+ <div className="chat-header__brand" aria-label={mallName}>
42
+ <span className="chat-header__brand-mark" aria-hidden="true" />
43
+ <span className="chat-header__brand-name">{mallName}</span>
44
+ </div>
45
+ ) : null}
44
46
  <div className={`chat-header__group${!isExpanded ? ' chat-header__group--collapsed' : ''}`}>
45
- <button className="chat-header__btn" onClick={handleClear}>
46
- <img className="chat-header__btn-icon" src={ICON_CLEAR} alt="" />
47
+ <button type="button" className="chat-header__btn" onClick={handleClear}>
48
+ <Trash size={22} className="chat-header__btn-icon" />
47
49
  <span className="chat-header__btn-text">清空</span>
48
50
  </button>
49
51
  {enableTTS && (
50
- <button className="chat-header__btn" onClick={onToggleTTS}>
51
- <img className="chat-header__btn-icon" src={ttsAutoPlay ? ICON_AUDIO_ON : ICON_AUDIO_OFF} alt="" />
52
+ <button type="button" className="chat-header__btn" onClick={onToggleTTS}>
53
+ {ttsAutoPlay ? (
54
+ <SpeakerHigh size={22} className="chat-header__btn-icon" />
55
+ ) : (
56
+ <SpeakerSlash size={22} className="chat-header__btn-icon" />
57
+ )}
52
58
  <span className="chat-header__btn-text">{ttsAutoPlay ? '关闭' : '播放'}</span>
53
59
  </button>
54
60
  )}
55
61
  </div>
56
- <button className="chat-header__toggle" onClick={() => setIsExpanded((p) => !p)}>
62
+ <button
63
+ className="chat-header__toggle"
64
+ type="button"
65
+ aria-label={isExpanded ? '收起对话工具' : '展开对话工具'}
66
+ aria-expanded={isExpanded}
67
+ onClick={() => setIsExpanded((p) => !p)}
68
+ >
57
69
  <span className={`chat-header__arrow${isExpanded ? ' chat-header__arrow--up' : ''}`}>
58
- <CaretDown size={16} color="#4b6282" />
70
+ <CaretDown size={14} />
59
71
  </span>
60
72
  </button>
61
73
  </div>
@@ -13,17 +13,17 @@
13
13
  max-width: var(--tbox-content-max-width);
14
14
  margin: 0 auto;
15
15
  box-sizing: border-box;
16
- padding: 16px 24px calc(16px + env(safe-area-inset-bottom, 0px));
16
+ padding: 8PX var(--tbox-page-gutter) calc(8PX + env(safe-area-inset-bottom, 0PX));
17
17
  background: transparent;
18
18
 
19
19
  &__row {
20
20
  display: flex;
21
21
  align-items: flex-end;
22
- gap: 16px;
22
+ gap: 8PX;
23
23
  flex-wrap: wrap;
24
24
  }
25
25
 
26
- /* 连接终态阻断提示条(禁用输入 + 重试入口;配色沿用既有错误语义,不硬编码新色值) */
26
+ /* 连接终态阻断提示条(禁用输入 + 重试入口;配色沿用既有 token,不硬编码) */
27
27
  &__blocked {
28
28
  flex-basis: 100%;
29
29
  display: flex;
@@ -32,7 +32,7 @@
32
32
  padding: 12px 20px;
33
33
  border-radius: 12px;
34
34
  background: rgba(255, 241, 241, 0.9);
35
- color: #d5492f;
35
+ color: var(--tbox-error);
36
36
  font-size: 24px;
37
37
  line-height: 1.4;
38
38
 
@@ -46,7 +46,7 @@
46
46
  padding: 6px 20px;
47
47
  border: none;
48
48
  border-radius: 999px;
49
- background: #d5492f;
49
+ background: var(--tbox-primary);
50
50
  color: #fff;
51
51
  font-size: 22px;
52
52
  }
@@ -58,11 +58,19 @@
58
58
  align-items: flex-end;
59
59
  flex: 1;
60
60
  min-width: 0;
61
- gap: 28px;
61
+ gap: 14PX;
62
62
  background: var(--tbox-input-bg);
63
+ border: 1PX solid var(--tbox-border);
63
64
  border-radius: var(--tbox-input-radius);
64
65
  padding: var(--tbox-input-padding);
65
- box-shadow: 0 2px 20px rgba(110, 139, 180, 0.24);
66
+ box-shadow: 0 1PX 10PX rgba(110, 139, 180, 0.24);
67
+ transition: border-color var(--tbox-transition-fast), box-shadow var(--tbox-transition-fast);
68
+ }
69
+
70
+ /* 聚焦蓝环 */
71
+ &__wrapper:focus-within {
72
+ border-color: var(--tbox-primary);
73
+ box-shadow: 0 0 0 1.5PX rgba(9, 107, 250, 0.12);
66
74
  }
67
75
 
68
76
  &__field {
@@ -73,14 +81,14 @@
73
81
  outline: none;
74
82
  background: transparent;
75
83
  font-family: inherit;
76
- font-size: 30px;
77
- line-height: 48px;
78
- max-height: 200px;
84
+ font-size: 15PX;
85
+ line-height: 24PX;
86
+ max-height: 100PX;
79
87
  color: var(--tbox-text-primary);
80
88
  padding: 0;
81
89
 
82
90
  &::placeholder {
83
- color: rgba(16, 41, 83, 0.6);
91
+ color: var(--tbox-text-muted);
84
92
  }
85
93
 
86
94
  /* 连接终态阻断(登录失败/scope 拒绝):禁用态弱化输入框 */
@@ -106,44 +114,44 @@
106
114
  }
107
115
 
108
116
  &__disclaimer {
109
- margin-top: 10px;
117
+ margin-top: 5PX;
110
118
  text-align: center;
111
- font-size: 20px;
119
+ font-size: 10PX;
112
120
  line-height: 1.5;
113
- color: rgba(16, 41, 83, 0.32);
121
+ color: var(--tbox-text-muted);
114
122
  }
115
123
  }
116
124
 
117
125
  /* 新对话按钮(药丸左侧,圆形) */
118
126
  .tbox-new-chat-btn {
119
- width: 88px;
120
- height: 88px;
127
+ width: 44PX;
128
+ height: 44PX;
121
129
  flex-shrink: 0;
122
130
  display: flex;
123
131
  align-items: center;
124
132
  justify-content: center;
125
133
  border-radius: 50%;
126
134
  background: rgba(255, 255, 255, 0.5);
127
- box-shadow: 0 2px 20px rgba(110, 139, 180, 0.24);
135
+ box-shadow: 0 1PX 10PX rgba(110, 139, 180, 0.24);
128
136
 
129
137
  &__icon {
130
- width: 44px;
131
- height: 44px;
138
+ width: 22PX;
139
+ height: 22PX;
132
140
  }
133
141
  }
134
142
 
135
143
  /* 语音切换 */
136
144
  .tbox-voice-toggle {
137
- width: 44px;
138
- height: 44px;
145
+ width: 22PX;
146
+ height: 22PX;
139
147
  flex-shrink: 0;
140
148
  display: flex;
141
149
  align-items: center;
142
150
  justify-content: center;
143
151
 
144
152
  &__icon {
145
- width: 44px;
146
- height: 44px;
153
+ width: 22PX;
154
+ height: 22PX;
147
155
  }
148
156
 
149
157
  &--active {
@@ -161,7 +169,7 @@
161
169
  align-items: center;
162
170
  justify-content: center;
163
171
  flex-shrink: 0;
164
- transition: transform 0.25s ease;
172
+ transition: transform var(--tbox-motion-duration-base) ease;
165
173
 
166
174
  &--open {
167
175
  transform: rotate(45deg);
@@ -183,13 +191,13 @@
183
191
  overflow: hidden;
184
192
  opacity: 0;
185
193
  transform: translateY(10px);
186
- transition: all 0.2s ease-in;
194
+ transition: all var(--tbox-motion-duration-fast) ease-in;
187
195
  will-change: opacity, transform;
188
196
 
189
197
  &--visible {
190
198
  opacity: 1;
191
199
  transform: translateY(0);
192
- transition: all 0.25s ease-out;
200
+ transition: all var(--tbox-motion-duration-base) ease-out;
193
201
  }
194
202
 
195
203
  &__option {
@@ -204,7 +212,7 @@
204
212
  background: rgba(0, 0, 0, 0.04);
205
213
  opacity: 0;
206
214
  transform: translateY(15px);
207
- transition: all 0.3s ease-out;
215
+ transition: all var(--tbox-motion-duration-base) ease-out;
208
216
  will-change: opacity, transform;
209
217
 
210
218
  & + & {
@@ -238,7 +246,7 @@
238
246
  margin-left: 8px;
239
247
  font-weight: 400;
240
248
  font-size: 26px;
241
- color: #4b6282;
249
+ color: var(--tbox-text-secondary);
242
250
  line-height: 37px;
243
251
  }
244
252
  }
@@ -247,7 +255,7 @@
247
255
  .tbox-voice-press-field {
248
256
  flex: 1;
249
257
  min-width: 0;
250
- height: 48px;
258
+ height: 24PX;
251
259
  display: flex;
252
260
  align-items: center;
253
261
  justify-content: center;
@@ -264,13 +272,13 @@
264
272
  }
265
273
  &--pressing,
266
274
  &--recording-active {
267
- background: #eef4ff;
275
+ background: #eef4ff; /* mall-vis 语音按住浅蓝底 */
268
276
  }
269
277
  &--recording-cancel {
270
- background: #fff1f1;
278
+ background: #fff1f1; /* mall-vis 取消态浅红底 */
271
279
  }
272
280
  &--processing {
273
- background: #f5f7fb;
281
+ background: #f5f7fb; /* mall-vis 处理中浅灰底 */
274
282
  }
275
283
 
276
284
  &__content {
@@ -283,10 +291,10 @@
283
291
  }
284
292
 
285
293
  &__text {
286
- font-size: 30px;
294
+ font-size: 15PX;
287
295
  font-weight: 600;
288
- line-height: 48px;
289
- color: #1f3b63;
296
+ line-height: 24PX;
297
+ color: var(--tbox-text-primary);
290
298
  }
291
299
  }
292
300
 
@@ -312,7 +320,7 @@
312
320
  display: flex;
313
321
  align-items: flex-end;
314
322
  justify-content: center;
315
- padding: 0 24px calc(56px + env(safe-area-inset-bottom, 16px));
323
+ padding: 0 var(--tbox-page-gutter) calc(56px + env(safe-area-inset-bottom, 16px));
316
324
  box-sizing: border-box;
317
325
  user-select: none;
318
326
  -webkit-user-select: none;
@@ -350,13 +358,13 @@
350
358
  &__tip {
351
359
  font-size: 24px;
352
360
  line-height: 48px;
353
- color: rgba(31, 59, 99, 0.45);
361
+ color: var(--tbox-text-muted);
354
362
  font-weight: 500;
355
363
  margin-bottom: 32px;
356
364
  text-align: center;
357
365
 
358
366
  &--cancel {
359
- color: #ff3141;
367
+ color: var(--tbox-error);
360
368
  }
361
369
  }
362
370
 
@@ -367,7 +375,7 @@
367
375
  display: flex;
368
376
  align-items: center;
369
377
  justify-content: center;
370
- background-image: linear-gradient(162deg, #30a2fd -22%, #868af4 108%);
378
+ background-image: linear-gradient(162deg, var(--tbox-primary) -22%, var(--tbox-primary-dark) 108%);
371
379
  box-shadow: 0 12px 28px rgba(80, 111, 255, 0.24);
372
380
 
373
381
  &--cancel {
@@ -0,0 +1,156 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { createRoot } from 'react-dom/client';
5
+ import type { VoiceInteractionState } from './voice/machine';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ interactionState: 'idle' as VoiceInteractionState,
9
+ }));
10
+
11
+ vi.mock('@tbox.cn/app-sdk/client', () => ({
12
+ isInAlipayShell: () => false,
13
+ useASRAdapter: () => undefined,
14
+ }));
15
+
16
+ vi.mock('../../hooks/useMediaUpload', () => ({
17
+ useMediaUpload: () => ({
18
+ items: [],
19
+ addFiles: vi.fn(),
20
+ addFromBridge: vi.fn(),
21
+ removeItem: vi.fn(),
22
+ retry: vi.fn(),
23
+ clear: vi.fn(),
24
+ uploading: false,
25
+ }),
26
+ }));
27
+
28
+ vi.mock('../../components/MediaList', () => ({ MediaList: () => null }));
29
+
30
+ vi.mock('./voice/useVoicePress', () => ({
31
+ useVoicePress: () => ({
32
+ interactionState: mocks.interactionState,
33
+ overlayVisible: mocks.interactionState !== 'idle',
34
+ countdown: null,
35
+ handlers: {
36
+ onTouchStart: vi.fn(),
37
+ onTouchMove: vi.fn(),
38
+ onTouchEnd: vi.fn(),
39
+ onTouchCancel: vi.fn(),
40
+ },
41
+ }),
42
+ }));
43
+
44
+ import { ChatInput } from './index';
45
+ import { useChatConfigStore } from '../../chat-config';
46
+
47
+ afterEach(() => {
48
+ mocks.interactionState = 'idle';
49
+ // 重置回构建期快照(对齐模板 config/chat.json 出厂值;派生矩阵用例经 setState 驱动水合模拟)
50
+ useChatConfigStore.setState({
51
+ enableImageUpload: false,
52
+ enableVideoUpload: false,
53
+ enableTTS: false,
54
+ enableVoiceInput: true,
55
+ defaultInputMode: 'TEXT',
56
+ });
57
+ document.body.innerHTML = '';
58
+ });
59
+
60
+ describe('ChatInput 语音活动状态', () => {
61
+ it('idle、识别中和结束时依次向页面投影 false、true、false', async () => {
62
+ const onVoiceActivityChange = vi.fn();
63
+ const container = document.createElement('div');
64
+ document.body.appendChild(container);
65
+ const root = createRoot(container);
66
+ const render = () => root.render(
67
+ <ChatInput onSend={vi.fn()} onVoiceActivityChange={onVoiceActivityChange} />,
68
+ );
69
+
70
+ await act(async () => render());
71
+ expect(onVoiceActivityChange).toHaveBeenLastCalledWith(false);
72
+
73
+ mocks.interactionState = 'processing';
74
+ await act(async () => render());
75
+ expect(onVoiceActivityChange).toHaveBeenLastCalledWith(true);
76
+
77
+ mocks.interactionState = 'idle';
78
+ await act(async () => render());
79
+ expect(onVoiceActivityChange).toHaveBeenLastCalledWith(false);
80
+
81
+ await act(async () => root.unmount());
82
+ });
83
+ });
84
+
85
+ describe('ChatInput 派生模式(服务端 defaultInputMode 水合)', () => {
86
+ /** voice 态投影:录音模式显示"键盘"切换图标;text 态显示"语音"切换图标 */
87
+ const inVoiceMode = (container: HTMLElement) =>
88
+ container.querySelector('img[alt="键盘"]') !== null;
89
+
90
+ const renderInput = async (root: ReturnType<typeof createRoot>) => {
91
+ await act(async () => root.render(<ChatInput onSend={vi.fn()} />));
92
+ };
93
+
94
+ it('① voice 关 + defaultInputMode=VOICE → 强制 text(语音按钮隐藏)', async () => {
95
+ useChatConfigStore.setState({ enableVoiceInput: false, defaultInputMode: 'VOICE' });
96
+ const container = document.createElement('div');
97
+ document.body.appendChild(container);
98
+ const root = createRoot(container);
99
+ await renderInput(root);
100
+ expect(inVoiceMode(container)).toBe(false);
101
+ await act(async () => root.unmount());
102
+ });
103
+
104
+ it('② voice 开 + defaultInputMode=VOICE → 初始即 voice', async () => {
105
+ useChatConfigStore.setState({ enableVoiceInput: true, defaultInputMode: 'VOICE' });
106
+ const container = document.createElement('div');
107
+ document.body.appendChild(container);
108
+ const root = createRoot(container);
109
+ await renderInput(root);
110
+ expect(inVoiceMode(container)).toBe(true);
111
+ await act(async () => root.unmount());
112
+ });
113
+
114
+ it('③ 初值 TEXT 渲染后水合 VOICE → 派生自动转 voice(useState 捕获竞态修复)', async () => {
115
+ useChatConfigStore.setState({ enableVoiceInput: true, defaultInputMode: 'TEXT' });
116
+ const container = document.createElement('div');
117
+ document.body.appendChild(container);
118
+ const root = createRoot(container);
119
+ await renderInput(root);
120
+ expect(inVoiceMode(container)).toBe(false);
121
+ // 模拟 HELLO_ACK 晚到水合(服务端 defaultInputMode='VOICE')
122
+ useChatConfigStore.setState({ defaultInputMode: 'VOICE' });
123
+ await renderInput(root);
124
+ expect(inVoiceMode(container)).toBe(true);
125
+ await act(async () => root.unmount());
126
+ });
127
+
128
+ it('④ 用户手动切 text 后水合 VOICE 到达 → 显式选择优先,保持 text', async () => {
129
+ useChatConfigStore.setState({ enableVoiceInput: true, defaultInputMode: 'VOICE' });
130
+ const container = document.createElement('div');
131
+ document.body.appendChild(container);
132
+ const root = createRoot(container);
133
+ await renderInput(root);
134
+ expect(inVoiceMode(container)).toBe(true);
135
+ // 用户手动切回 text(voice 态的"键盘"切换钮——div 容器 onClick)
136
+ const keyboardToggle = container.querySelector<HTMLElement>('.tbox-voice-toggle')!;
137
+ await act(async () => keyboardToggle.click());
138
+ await renderInput(root);
139
+ expect(inVoiceMode(container)).toBe(false);
140
+ // 水合再次下发 VOICE(如重连回显)——用户显式选择仍优先
141
+ useChatConfigStore.setState({ defaultInputMode: 'VOICE' });
142
+ await renderInput(root);
143
+ expect(inVoiceMode(container)).toBe(false);
144
+ await act(async () => root.unmount());
145
+ });
146
+
147
+ it('⑤ voice 开 + defaultInputMode=TEXT → text', async () => {
148
+ useChatConfigStore.setState({ enableVoiceInput: true, defaultInputMode: 'TEXT' });
149
+ const container = document.createElement('div');
150
+ document.body.appendChild(container);
151
+ const root = createRoot(container);
152
+ await renderInput(root);
153
+ expect(inVoiceMode(container)).toBe(false);
154
+ await act(async () => root.unmount());
155
+ });
156
+ });