@steerable/agent-shell-web 0.6.15
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/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { LuCheck, LuChevronLeft, LuPencil, LuWand } from 'react-icons/lu';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AskUserQuestionMenu — W8 结构化提问的分步菜单卡片。
|
|
6
|
+
*
|
|
7
|
+
* 替代 `@steerable/agent-ui` 的 `AskUserQuestionsCard`:
|
|
8
|
+
* - 一次只展示一个问题,顶部显示进度(问题 1/3);
|
|
9
|
+
* - select 题渲染为纵向菜单,模型给出的 options 是菜单项;
|
|
10
|
+
* - 菜单底部追加「其他 / 自定义」:选中后展开输入框,用户输入直接作为
|
|
11
|
+
* 该题答案(不会用补充信息再创建新问题);
|
|
12
|
+
* - 支持 ↑↓ 移动焦点,Enter 选中/切换;单选 Enter/点击 即提交并进入
|
|
13
|
+
* 下一题(或提交全部),多选勾选后用「下一题 / 提交」按钮;
|
|
14
|
+
* - 提交时仍返回 `{ [questionId]: string | string[] }`,与主进程/
|
|
15
|
+
* sidecar 的 answers 映射契约保持一致。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type AnswerValue = string | string[];
|
|
19
|
+
type RawQuestion = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
interface AskOption {
|
|
22
|
+
label: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface AskQuestion {
|
|
27
|
+
id: string;
|
|
28
|
+
text: string;
|
|
29
|
+
type: 'select' | 'text' | 'password';
|
|
30
|
+
options: AskOption[];
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
multiSelect: boolean;
|
|
33
|
+
detail?: string;
|
|
34
|
+
header?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeOption(raw: unknown): AskOption | null {
|
|
38
|
+
if (typeof raw === 'string') return { label: raw };
|
|
39
|
+
if (raw && typeof raw === 'object') {
|
|
40
|
+
const obj = raw as Record<string, unknown>;
|
|
41
|
+
if (typeof obj.label === 'string') {
|
|
42
|
+
return {
|
|
43
|
+
label: obj.label,
|
|
44
|
+
description: typeof obj.description === 'string' ? obj.description : undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeQuestion(raw: RawQuestion): AskQuestion | null {
|
|
52
|
+
if (typeof raw.id !== 'string') return null;
|
|
53
|
+
const text =
|
|
54
|
+
typeof raw.text === 'string'
|
|
55
|
+
? raw.text
|
|
56
|
+
: typeof raw.question === 'string'
|
|
57
|
+
? raw.question
|
|
58
|
+
: '';
|
|
59
|
+
if (!text) return null;
|
|
60
|
+
|
|
61
|
+
const rawType = raw.type;
|
|
62
|
+
const inputType: AskQuestion['type'] =
|
|
63
|
+
rawType === 'text' || rawType === 'password' ? rawType : 'select';
|
|
64
|
+
|
|
65
|
+
const options = Array.isArray(raw.options)
|
|
66
|
+
? (raw.options.map(normalizeOption).filter(Boolean) as AskOption[])
|
|
67
|
+
: [];
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
id: raw.id,
|
|
71
|
+
text,
|
|
72
|
+
type: options.length > 0 ? 'select' : inputType === 'select' ? 'text' : inputType,
|
|
73
|
+
options,
|
|
74
|
+
placeholder: typeof raw.placeholder === 'string' ? raw.placeholder : undefined,
|
|
75
|
+
multiSelect: raw.multiSelect === true,
|
|
76
|
+
detail: typeof raw.detail === 'string' ? raw.detail : undefined,
|
|
77
|
+
header: typeof raw.header === 'string' ? raw.header : undefined,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeQuestions(raw: Array<Record<string, unknown>>): AskQuestion[] {
|
|
82
|
+
return raw.map(normalizeQuestion).filter(Boolean) as AskQuestion[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function initialDraft(question: AskQuestion, answer: AnswerValue | undefined): {
|
|
86
|
+
selected: string[];
|
|
87
|
+
customMode: boolean;
|
|
88
|
+
customText: string;
|
|
89
|
+
} {
|
|
90
|
+
const optionLabels = question.options.map((o) => o.label);
|
|
91
|
+
if (Array.isArray(answer)) {
|
|
92
|
+
const selected = answer.filter((v) => optionLabels.includes(v));
|
|
93
|
+
const customBits = answer.filter((v) => !optionLabels.includes(v));
|
|
94
|
+
return {
|
|
95
|
+
selected,
|
|
96
|
+
customMode: customBits.length > 0,
|
|
97
|
+
customText: customBits.join('、'),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (typeof answer === 'string' && answer.length > 0) {
|
|
101
|
+
if (optionLabels.includes(answer)) {
|
|
102
|
+
return { selected: [answer], customMode: false, customText: '' };
|
|
103
|
+
}
|
|
104
|
+
return { selected: [], customMode: true, customText: answer };
|
|
105
|
+
}
|
|
106
|
+
return { selected: [], customMode: false, customText: '' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function Footer({
|
|
110
|
+
canGoBack,
|
|
111
|
+
onBack,
|
|
112
|
+
onAutoContinue,
|
|
113
|
+
hint,
|
|
114
|
+
children,
|
|
115
|
+
}: {
|
|
116
|
+
canGoBack: boolean;
|
|
117
|
+
onBack?: () => void;
|
|
118
|
+
onAutoContinue?: () => void;
|
|
119
|
+
hint?: string;
|
|
120
|
+
children?: React.ReactNode;
|
|
121
|
+
}) {
|
|
122
|
+
return (
|
|
123
|
+
<div className="mt-4 flex items-center justify-between gap-3 border-t border-agent-border px-4 py-3">
|
|
124
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
125
|
+
{canGoBack && onBack && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={onBack}
|
|
129
|
+
className="inline-flex items-center gap-1 rounded-agent-md border border-agent-border px-2.5 py-1.5 text-xs font-medium text-agent-foreground transition hover:bg-agent-muted"
|
|
130
|
+
>
|
|
131
|
+
<LuChevronLeft size={12} />
|
|
132
|
+
上一题
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
135
|
+
{onAutoContinue && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={onAutoContinue}
|
|
139
|
+
className="inline-flex items-center gap-1 rounded-agent-md px-2.5 py-1.5 text-xs font-medium text-agent-muted-foreground transition hover:bg-agent-muted hover:text-agent-foreground"
|
|
140
|
+
>
|
|
141
|
+
<LuWand size={12} />
|
|
142
|
+
交给我决定
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
147
|
+
<span className="hidden truncate text-[11px] text-agent-muted-foreground sm:inline">
|
|
148
|
+
{hint}
|
|
149
|
+
</span>
|
|
150
|
+
{children}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function PrimaryButton({
|
|
157
|
+
disabled,
|
|
158
|
+
isLast,
|
|
159
|
+
onClick,
|
|
160
|
+
}: {
|
|
161
|
+
disabled?: boolean;
|
|
162
|
+
isLast: boolean;
|
|
163
|
+
onClick: () => void;
|
|
164
|
+
}) {
|
|
165
|
+
return (
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={onClick}
|
|
169
|
+
disabled={disabled}
|
|
170
|
+
className={[
|
|
171
|
+
'inline-flex items-center gap-1.5 rounded-agent-md px-4 py-1.5 text-xs font-medium transition',
|
|
172
|
+
disabled
|
|
173
|
+
? 'cursor-not-allowed bg-agent-muted text-agent-muted-foreground'
|
|
174
|
+
: 'bg-agent-foreground text-agent-canvas shadow-sm hover:opacity-90',
|
|
175
|
+
].join(' ')}
|
|
176
|
+
>
|
|
177
|
+
<LuCheck size={12} />
|
|
178
|
+
{isLast ? '提交' : '下一题'}
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function SelectQuestionStep({
|
|
184
|
+
question,
|
|
185
|
+
initialAnswer,
|
|
186
|
+
isLast,
|
|
187
|
+
onCommit,
|
|
188
|
+
onBack,
|
|
189
|
+
onAutoContinue,
|
|
190
|
+
}: {
|
|
191
|
+
question: AskQuestion;
|
|
192
|
+
initialAnswer: AnswerValue | undefined;
|
|
193
|
+
isLast: boolean;
|
|
194
|
+
onCommit: (value: AnswerValue) => void;
|
|
195
|
+
onBack?: () => void;
|
|
196
|
+
onAutoContinue?: () => void;
|
|
197
|
+
}) {
|
|
198
|
+
const initial = useMemo(
|
|
199
|
+
() => initialDraft(question, initialAnswer),
|
|
200
|
+
[question, initialAnswer],
|
|
201
|
+
);
|
|
202
|
+
const [selected, setSelected] = useState<string[]>(initial.selected);
|
|
203
|
+
const [customMode, setCustomMode] = useState(initial.customMode);
|
|
204
|
+
const [customText, setCustomText] = useState(initial.customText);
|
|
205
|
+
|
|
206
|
+
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
207
|
+
const customInputRef = useRef<HTMLInputElement | null>(null);
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (customMode) {
|
|
211
|
+
customInputRef.current?.focus();
|
|
212
|
+
}
|
|
213
|
+
}, [customMode]);
|
|
214
|
+
|
|
215
|
+
const commitOption = (label: string) => {
|
|
216
|
+
if (question.multiSelect) {
|
|
217
|
+
setSelected((prev) =>
|
|
218
|
+
prev.includes(label) ? prev.filter((v) => v !== label) : [...prev, label],
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
onCommit(label);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const commitCustom = () => {
|
|
226
|
+
const text = customText.trim();
|
|
227
|
+
if (!text) return;
|
|
228
|
+
if (question.multiSelect) {
|
|
229
|
+
const merged = [...selected];
|
|
230
|
+
if (!merged.includes(text)) merged.push(text);
|
|
231
|
+
onCommit(merged);
|
|
232
|
+
} else {
|
|
233
|
+
onCommit(text);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const commitMulti = () => {
|
|
238
|
+
const text = customText.trim();
|
|
239
|
+
const merged = [...selected];
|
|
240
|
+
if (customMode && text && !merged.includes(text)) merged.push(text);
|
|
241
|
+
if (merged.length === 0) return;
|
|
242
|
+
onCommit(merged);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const focusItem = (index: number) => {
|
|
246
|
+
const item = optionRefs.current[index];
|
|
247
|
+
if (item) {
|
|
248
|
+
item.focus();
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const handleListKeyDown = (e: React.KeyboardEvent) => {
|
|
255
|
+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
const count = question.options.length + 1; // options + 「其他 / 自定义」
|
|
258
|
+
const current = optionRefs.current.findIndex((el) => el === document.activeElement);
|
|
259
|
+
const next = e.key === 'ArrowDown' ? (current + 1) % count : (current - 1 + count) % count;
|
|
260
|
+
focusItem(next);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const renderOption = (opt: AskOption, index: number) => {
|
|
264
|
+
const active = selected.includes(opt.label);
|
|
265
|
+
return (
|
|
266
|
+
<button
|
|
267
|
+
key={opt.label}
|
|
268
|
+
type="button"
|
|
269
|
+
ref={(el) => {
|
|
270
|
+
optionRefs.current[index] = el;
|
|
271
|
+
}}
|
|
272
|
+
onClick={() => commitOption(opt.label)}
|
|
273
|
+
onKeyDown={(e) => {
|
|
274
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
commitOption(opt.label);
|
|
277
|
+
}
|
|
278
|
+
}}
|
|
279
|
+
role={question.multiSelect ? 'checkbox' : 'radio'}
|
|
280
|
+
aria-checked={active}
|
|
281
|
+
className={[
|
|
282
|
+
'flex w-full items-center gap-2 rounded-agent-md border px-3 py-2 text-left text-sm transition',
|
|
283
|
+
active
|
|
284
|
+
? 'border-agent-foreground bg-agent-muted text-agent-foreground'
|
|
285
|
+
: 'border-agent-border bg-agent-canvas text-agent-foreground hover:bg-agent-muted',
|
|
286
|
+
].join(' ')}
|
|
287
|
+
>
|
|
288
|
+
<span
|
|
289
|
+
className={[
|
|
290
|
+
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px]',
|
|
291
|
+
active
|
|
292
|
+
? 'border-agent-foreground bg-agent-foreground text-agent-canvas'
|
|
293
|
+
: 'border-agent-border text-transparent',
|
|
294
|
+
].join(' ')}
|
|
295
|
+
>
|
|
296
|
+
<LuCheck size={10} />
|
|
297
|
+
</span>
|
|
298
|
+
<span className="min-w-0 flex-1">
|
|
299
|
+
<span className="block truncate">{opt.label}</span>
|
|
300
|
+
{opt.description && (
|
|
301
|
+
<span className="mt-0.5 block text-xs text-agent-muted-foreground">
|
|
302
|
+
{opt.description}
|
|
303
|
+
</span>
|
|
304
|
+
)}
|
|
305
|
+
</span>
|
|
306
|
+
{!question.multiSelect && (
|
|
307
|
+
<kbd className="hidden rounded border border-agent-border px-1 text-[10px] text-agent-muted-foreground sm:inline">
|
|
308
|
+
↵
|
|
309
|
+
</kbd>
|
|
310
|
+
)}
|
|
311
|
+
</button>
|
|
312
|
+
);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const renderCustomOption = (index: number) => {
|
|
316
|
+
const active = customMode;
|
|
317
|
+
return (
|
|
318
|
+
<button
|
|
319
|
+
key="__custom__"
|
|
320
|
+
type="button"
|
|
321
|
+
ref={(el) => {
|
|
322
|
+
optionRefs.current[index] = el;
|
|
323
|
+
}}
|
|
324
|
+
onClick={() => {
|
|
325
|
+
setCustomMode((prev) => !prev);
|
|
326
|
+
setCustomText('');
|
|
327
|
+
}}
|
|
328
|
+
onKeyDown={(e) => {
|
|
329
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
330
|
+
e.preventDefault();
|
|
331
|
+
setCustomMode((prev) => !prev);
|
|
332
|
+
setCustomText('');
|
|
333
|
+
}
|
|
334
|
+
}}
|
|
335
|
+
role={question.multiSelect ? 'checkbox' : 'radio'}
|
|
336
|
+
aria-checked={active}
|
|
337
|
+
className={[
|
|
338
|
+
'flex w-full items-center gap-2 rounded-agent-md border border-dashed px-3 py-2 text-left text-sm transition',
|
|
339
|
+
active
|
|
340
|
+
? 'border-agent-foreground bg-agent-muted text-agent-foreground'
|
|
341
|
+
: 'border-agent-border bg-agent-canvas text-agent-muted-foreground hover:bg-agent-muted hover:text-agent-foreground',
|
|
342
|
+
].join(' ')}
|
|
343
|
+
>
|
|
344
|
+
<span
|
|
345
|
+
className={[
|
|
346
|
+
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px]',
|
|
347
|
+
active
|
|
348
|
+
? 'border-agent-foreground bg-agent-foreground text-agent-canvas'
|
|
349
|
+
: 'border-agent-border text-transparent',
|
|
350
|
+
].join(' ')}
|
|
351
|
+
>
|
|
352
|
+
<LuCheck size={10} />
|
|
353
|
+
</span>
|
|
354
|
+
<LuPencil size={12} className="shrink-0" />
|
|
355
|
+
<span className="min-w-0 flex-1">其他 / 自定义</span>
|
|
356
|
+
</button>
|
|
357
|
+
);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<div>
|
|
362
|
+
<div className="space-y-2 px-4 py-3">
|
|
363
|
+
{question.header && (
|
|
364
|
+
<p className="text-[11px] font-medium uppercase tracking-wide text-agent-muted-foreground">
|
|
365
|
+
{question.header}
|
|
366
|
+
</p>
|
|
367
|
+
)}
|
|
368
|
+
<p className="text-sm font-medium text-agent-foreground">{question.text}</p>
|
|
369
|
+
{question.detail && (
|
|
370
|
+
<p className="whitespace-pre-line text-xs text-agent-muted-foreground">
|
|
371
|
+
{question.detail}
|
|
372
|
+
</p>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{!customMode || question.multiSelect ? (
|
|
376
|
+
<div
|
|
377
|
+
className="space-y-1.5"
|
|
378
|
+
role={question.multiSelect ? 'group' : 'radiogroup'}
|
|
379
|
+
onKeyDown={handleListKeyDown}
|
|
380
|
+
>
|
|
381
|
+
{question.options.map((opt, index) => renderOption(opt, index))}
|
|
382
|
+
{renderCustomOption(question.options.length)}
|
|
383
|
+
</div>
|
|
384
|
+
) : null}
|
|
385
|
+
|
|
386
|
+
{customMode && question.multiSelect && (
|
|
387
|
+
<input
|
|
388
|
+
ref={customInputRef}
|
|
389
|
+
type="text"
|
|
390
|
+
value={customText}
|
|
391
|
+
onChange={(e) => setCustomText(e.target.value)}
|
|
392
|
+
onKeyDown={(e) => {
|
|
393
|
+
if (e.key === 'Enter') {
|
|
394
|
+
e.preventDefault();
|
|
395
|
+
commitMulti();
|
|
396
|
+
}
|
|
397
|
+
}}
|
|
398
|
+
placeholder={question.placeholder || '输入你的补充...'}
|
|
399
|
+
className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground focus:border-agent-foreground focus:ring-1 focus:ring-agent-foreground"
|
|
400
|
+
/>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{customMode && !question.multiSelect && (
|
|
404
|
+
<div className="space-y-2 rounded-agent-md border border-agent-border bg-agent-muted/40 p-3">
|
|
405
|
+
<input
|
|
406
|
+
ref={customInputRef}
|
|
407
|
+
type="text"
|
|
408
|
+
value={customText}
|
|
409
|
+
onChange={(e) => setCustomText(e.target.value)}
|
|
410
|
+
onKeyDown={(e) => {
|
|
411
|
+
if (e.key === 'Enter') {
|
|
412
|
+
e.preventDefault();
|
|
413
|
+
commitCustom();
|
|
414
|
+
}
|
|
415
|
+
}}
|
|
416
|
+
placeholder={question.placeholder || '输入你的回答...'}
|
|
417
|
+
className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground focus:border-agent-foreground focus:ring-1 focus:ring-agent-foreground"
|
|
418
|
+
/>
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
onClick={() => {
|
|
422
|
+
setCustomMode(false);
|
|
423
|
+
setCustomText('');
|
|
424
|
+
}}
|
|
425
|
+
className="text-xs text-agent-muted-foreground transition hover:text-agent-foreground"
|
|
426
|
+
>
|
|
427
|
+
← 返回选项
|
|
428
|
+
</button>
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<Footer
|
|
434
|
+
canGoBack={Boolean(onBack)}
|
|
435
|
+
onBack={onBack}
|
|
436
|
+
onAutoContinue={onAutoContinue}
|
|
437
|
+
hint={question.multiSelect ? '↑↓ 移动,Enter 勾选' : '↑↓ 选择,Enter 确认'}
|
|
438
|
+
>
|
|
439
|
+
{question.multiSelect ? (
|
|
440
|
+
<PrimaryButton
|
|
441
|
+
disabled={selected.length === 0 && !(customMode && customText.trim())}
|
|
442
|
+
isLast={isLast}
|
|
443
|
+
onClick={commitMulti}
|
|
444
|
+
/>
|
|
445
|
+
) : (
|
|
446
|
+
customMode && (
|
|
447
|
+
<PrimaryButton
|
|
448
|
+
disabled={!customText.trim()}
|
|
449
|
+
isLast={isLast}
|
|
450
|
+
onClick={commitCustom}
|
|
451
|
+
/>
|
|
452
|
+
)
|
|
453
|
+
)}
|
|
454
|
+
</Footer>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function TextQuestionStep({
|
|
460
|
+
question,
|
|
461
|
+
initialAnswer,
|
|
462
|
+
isLast,
|
|
463
|
+
onCommit,
|
|
464
|
+
onBack,
|
|
465
|
+
onAutoContinue,
|
|
466
|
+
}: {
|
|
467
|
+
question: AskQuestion;
|
|
468
|
+
initialAnswer: AnswerValue | undefined;
|
|
469
|
+
isLast: boolean;
|
|
470
|
+
onCommit: (value: AnswerValue) => void;
|
|
471
|
+
onBack?: () => void;
|
|
472
|
+
onAutoContinue?: () => void;
|
|
473
|
+
}) {
|
|
474
|
+
const [value, setValue] = useState(
|
|
475
|
+
typeof initialAnswer === 'string' ? initialAnswer : '',
|
|
476
|
+
);
|
|
477
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
478
|
+
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
inputRef.current?.focus();
|
|
481
|
+
}, []);
|
|
482
|
+
|
|
483
|
+
const commit = () => {
|
|
484
|
+
const text = value.trim();
|
|
485
|
+
if (!text) return;
|
|
486
|
+
onCommit(text);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<div>
|
|
491
|
+
<div className="space-y-2 px-4 py-3">
|
|
492
|
+
{question.header && (
|
|
493
|
+
<p className="text-[11px] font-medium uppercase tracking-wide text-agent-muted-foreground">
|
|
494
|
+
{question.header}
|
|
495
|
+
</p>
|
|
496
|
+
)}
|
|
497
|
+
<p className="text-sm font-medium text-agent-foreground">{question.text}</p>
|
|
498
|
+
{question.detail && (
|
|
499
|
+
<p className="whitespace-pre-line text-xs text-agent-muted-foreground">
|
|
500
|
+
{question.detail}
|
|
501
|
+
</p>
|
|
502
|
+
)}
|
|
503
|
+
<input
|
|
504
|
+
ref={inputRef}
|
|
505
|
+
type={question.type === 'password' ? 'password' : 'text'}
|
|
506
|
+
value={value}
|
|
507
|
+
onChange={(e) => setValue(e.target.value)}
|
|
508
|
+
onKeyDown={(e) => {
|
|
509
|
+
if (e.key === 'Enter') {
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
commit();
|
|
512
|
+
}
|
|
513
|
+
}}
|
|
514
|
+
placeholder={question.placeholder || '输入你的回答...'}
|
|
515
|
+
className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground focus:border-agent-foreground focus:ring-1 focus:ring-agent-foreground"
|
|
516
|
+
/>
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<Footer
|
|
520
|
+
canGoBack={Boolean(onBack)}
|
|
521
|
+
onBack={onBack}
|
|
522
|
+
onAutoContinue={onAutoContinue}
|
|
523
|
+
hint="Enter 确认"
|
|
524
|
+
>
|
|
525
|
+
<PrimaryButton disabled={!value.trim()} isLast={isLast} onClick={commit} />
|
|
526
|
+
</Footer>
|
|
527
|
+
</div>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export interface AskUserQuestionMenuProps {
|
|
532
|
+
intro: string;
|
|
533
|
+
questions: Array<Record<string, unknown>>;
|
|
534
|
+
onSubmit: (answers: Record<string, AnswerValue>) => void;
|
|
535
|
+
onAutoContinue?: () => void;
|
|
536
|
+
bottomHint?: string;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function AskUserQuestionMenu({
|
|
540
|
+
intro,
|
|
541
|
+
questions,
|
|
542
|
+
onSubmit,
|
|
543
|
+
onAutoContinue,
|
|
544
|
+
bottomHint,
|
|
545
|
+
}: AskUserQuestionMenuProps) {
|
|
546
|
+
const normalized = useMemo(() => normalizeQuestions(questions), [questions]);
|
|
547
|
+
const [step, setStep] = useState(0);
|
|
548
|
+
const [answers, setAnswers] = useState<Record<string, AnswerValue>>({});
|
|
549
|
+
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
if (normalized.length === 0) {
|
|
552
|
+
onSubmit({});
|
|
553
|
+
}
|
|
554
|
+
}, [normalized.length, onSubmit]);
|
|
555
|
+
|
|
556
|
+
const current = normalized[Math.min(step, normalized.length - 1)] ?? null;
|
|
557
|
+
const isLast = normalized.length > 0 && step >= normalized.length - 1;
|
|
558
|
+
|
|
559
|
+
const commit = useCallback(
|
|
560
|
+
(value: AnswerValue) => {
|
|
561
|
+
if (!current) return;
|
|
562
|
+
const next = { ...answers, [current.id]: value };
|
|
563
|
+
setAnswers(next);
|
|
564
|
+
if (isLast) {
|
|
565
|
+
onSubmit(next);
|
|
566
|
+
} else {
|
|
567
|
+
setStep((s) => s + 1);
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
[answers, current, isLast, onSubmit],
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const goBack = useCallback(() => {
|
|
574
|
+
setStep((s) => Math.max(0, s - 1));
|
|
575
|
+
}, []);
|
|
576
|
+
|
|
577
|
+
if (!current) return null;
|
|
578
|
+
|
|
579
|
+
return (
|
|
580
|
+
<div className="overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas text-sm text-agent-foreground shadow-xl">
|
|
581
|
+
<div className="flex items-start justify-between gap-3 border-b border-agent-border px-4 py-3">
|
|
582
|
+
<div className="min-w-0 flex-1">
|
|
583
|
+
<p className="text-sm font-medium text-agent-foreground">
|
|
584
|
+
{intro || '需要你的输入'}
|
|
585
|
+
</p>
|
|
586
|
+
<p className="mt-0.5 text-[11px] text-agent-muted-foreground">
|
|
587
|
+
问题 {step + 1} / {normalized.length}
|
|
588
|
+
{bottomHint ? ` · ${bottomHint}` : ''}
|
|
589
|
+
</p>
|
|
590
|
+
</div>
|
|
591
|
+
<span className="rounded-full border border-agent-border px-2 py-0.5 text-[10px] font-medium text-agent-muted-foreground">
|
|
592
|
+
{current.type === 'password' ? '密码输入' : current.multiSelect ? '可多选' : '菜单选择'}
|
|
593
|
+
</span>
|
|
594
|
+
</div>
|
|
595
|
+
|
|
596
|
+
{current.type === 'select' ? (
|
|
597
|
+
<SelectQuestionStep
|
|
598
|
+
key={`${step}-${current.id}`}
|
|
599
|
+
question={current}
|
|
600
|
+
initialAnswer={answers[current.id]}
|
|
601
|
+
isLast={isLast}
|
|
602
|
+
onCommit={commit}
|
|
603
|
+
onBack={step > 0 ? goBack : undefined}
|
|
604
|
+
onAutoContinue={onAutoContinue}
|
|
605
|
+
/>
|
|
606
|
+
) : (
|
|
607
|
+
<TextQuestionStep
|
|
608
|
+
key={`${step}-${current.id}`}
|
|
609
|
+
question={current}
|
|
610
|
+
initialAnswer={answers[current.id]}
|
|
611
|
+
isLast={isLast}
|
|
612
|
+
onCommit={commit}
|
|
613
|
+
onBack={step > 0 ? goBack : undefined}
|
|
614
|
+
onAutoContinue={onAutoContinue}
|
|
615
|
+
/>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export default AskUserQuestionMenu;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssistantMessage 时间戳行:分享对话的入口从标题栏挪到最近一条助手回复
|
|
3
|
+
* 时间戳行上的分享、复制、重新生成共用悬停显隐。
|
|
4
|
+
*/
|
|
5
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
8
|
+
import { AssistantMessage } from './AssistantMessage';
|
|
9
|
+
|
|
10
|
+
afterEach(cleanup);
|
|
11
|
+
|
|
12
|
+
const MESSAGE: ChatMessage = {
|
|
13
|
+
id: 'm1',
|
|
14
|
+
role: 'assistant',
|
|
15
|
+
content: '问好完成。',
|
|
16
|
+
createdAt: '2026-09-13T09:03:00.000Z',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('AssistantMessage 分享', () => {
|
|
20
|
+
it('没有 onShare 时不画分享按钮', () => {
|
|
21
|
+
render(
|
|
22
|
+
<AssistantMessage
|
|
23
|
+
message={MESSAGE}
|
|
24
|
+
isStreaming={false}
|
|
25
|
+
agents={[]}
|
|
26
|
+
currentAgent={null}
|
|
27
|
+
/>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.queryByRole('button', { name: '分享对话截图' })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('流式中不画分享按钮', () => {
|
|
33
|
+
render(
|
|
34
|
+
<AssistantMessage
|
|
35
|
+
message={MESSAGE}
|
|
36
|
+
isStreaming
|
|
37
|
+
agents={[]}
|
|
38
|
+
currentAgent={null}
|
|
39
|
+
onShare={vi.fn()}
|
|
40
|
+
/>,
|
|
41
|
+
);
|
|
42
|
+
expect(screen.queryByRole('button', { name: '分享对话截图' })).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('点分享会调用 onShare,成功后提示已复制', async () => {
|
|
46
|
+
const onShare = vi.fn().mockResolvedValue(true);
|
|
47
|
+
render(
|
|
48
|
+
<AssistantMessage
|
|
49
|
+
message={MESSAGE}
|
|
50
|
+
isStreaming={false}
|
|
51
|
+
agents={[]}
|
|
52
|
+
currentAgent={null}
|
|
53
|
+
onShare={onShare}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
fireEvent.click(screen.getByRole('button', { name: '分享对话截图' }));
|
|
58
|
+
expect(screen.getByRole('button', { name: '分享对话截图' }).className).toContain(
|
|
59
|
+
'group-hover/message:opacity-100',
|
|
60
|
+
);
|
|
61
|
+
await waitFor(() => expect(onShare).toHaveBeenCalledTimes(1));
|
|
62
|
+
await waitFor(() =>
|
|
63
|
+
expect(screen.getByRole('button', { name: '分享对话截图' }).getAttribute('title')).toBe(
|
|
64
|
+
'截图已复制到剪贴板',
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|