@steerable/agent-ui 0.2.3 → 0.2.5

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 (184) hide show
  1. package/dist/cards/ActionSegmentCard.d.ts +18 -0
  2. package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
  3. package/dist/cards/ActionSegmentCard.js +26 -0
  4. package/dist/cards/ActionSegmentCard.js.map +1 -0
  5. package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
  6. package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
  7. package/dist/cards/AnalysisDocumentCard.js +11 -0
  8. package/dist/cards/AnalysisDocumentCard.js.map +1 -0
  9. package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
  10. package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
  11. package/dist/cards/AskUserQuestionsCard.js +171 -0
  12. package/dist/cards/AskUserQuestionsCard.js.map +1 -0
  13. package/dist/cards/CoverageReportCard.d.ts +41 -0
  14. package/dist/cards/CoverageReportCard.d.ts.map +1 -0
  15. package/dist/cards/CoverageReportCard.js +55 -0
  16. package/dist/cards/CoverageReportCard.js.map +1 -0
  17. package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
  18. package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
  19. package/dist/cards/OrchestrationPlanCard.js +103 -0
  20. package/dist/cards/OrchestrationPlanCard.js.map +1 -0
  21. package/dist/cards/PlanSelectorCard.d.ts +40 -0
  22. package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
  23. package/dist/cards/PlanSelectorCard.js +106 -0
  24. package/dist/cards/PlanSelectorCard.js.map +1 -0
  25. package/dist/cards/PlanStepsCard.d.ts +18 -0
  26. package/dist/cards/PlanStepsCard.d.ts.map +1 -0
  27. package/dist/cards/PlanStepsCard.js +25 -0
  28. package/dist/cards/PlanStepsCard.js.map +1 -0
  29. package/dist/cards/QuizCard.d.ts +44 -0
  30. package/dist/cards/QuizCard.d.ts.map +1 -0
  31. package/dist/cards/QuizCard.js +113 -0
  32. package/dist/cards/QuizCard.js.map +1 -0
  33. package/dist/cards/ResearchPlanCard.d.ts +23 -0
  34. package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
  35. package/dist/cards/ResearchPlanCard.js +50 -0
  36. package/dist/cards/ResearchPlanCard.js.map +1 -0
  37. package/dist/cards/SearchSourcesCard.d.ts +29 -0
  38. package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
  39. package/dist/cards/SearchSourcesCard.js +83 -0
  40. package/dist/cards/SearchSourcesCard.js.map +1 -0
  41. package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
  42. package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
  43. package/dist/cards/SuggestedRepliesCard.js +14 -0
  44. package/dist/cards/SuggestedRepliesCard.js.map +1 -0
  45. package/dist/cards/SummaryMessageCard.d.ts +40 -0
  46. package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
  47. package/dist/cards/SummaryMessageCard.js +56 -0
  48. package/dist/cards/SummaryMessageCard.js.map +1 -0
  49. package/dist/cards/ThinkingProcessCard.d.ts +46 -0
  50. package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
  51. package/dist/cards/ThinkingProcessCard.js +79 -0
  52. package/dist/cards/ThinkingProcessCard.js.map +1 -0
  53. package/dist/cards/ToolExecutionCard.d.ts +21 -0
  54. package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
  55. package/dist/cards/ToolExecutionCard.js +65 -0
  56. package/dist/cards/ToolExecutionCard.js.map +1 -0
  57. package/dist/cards/icons.d.ts +38 -0
  58. package/dist/cards/icons.d.ts.map +1 -0
  59. package/dist/cards/icons.js +87 -0
  60. package/dist/cards/icons.js.map +1 -0
  61. package/dist/cards/index.d.ts +38 -0
  62. package/dist/cards/index.d.ts.map +1 -0
  63. package/dist/cards/index.js +24 -0
  64. package/dist/cards/index.js.map +1 -0
  65. package/dist/cards/types.d.ts +24 -0
  66. package/dist/cards/types.d.ts.map +1 -0
  67. package/dist/cards/types.js +7 -0
  68. package/dist/cards/types.js.map +1 -0
  69. package/dist/components/AgentSelector.d.ts +23 -0
  70. package/dist/components/AgentSelector.d.ts.map +1 -0
  71. package/dist/components/AgentSelector.js +63 -0
  72. package/dist/components/AgentSelector.js.map +1 -0
  73. package/dist/components/AgentSelector.stories.d.ts +12 -0
  74. package/dist/components/AgentSelector.stories.d.ts.map +1 -0
  75. package/dist/components/AgentSelector.stories.js +95 -0
  76. package/dist/components/AgentSelector.stories.js.map +1 -0
  77. package/dist/components/ChatPanel.d.ts +138 -21
  78. package/dist/components/ChatPanel.d.ts.map +1 -1
  79. package/dist/components/ChatPanel.js +181 -26
  80. package/dist/components/ChatPanel.js.map +1 -1
  81. package/dist/components/index.d.ts +3 -1
  82. package/dist/components/index.d.ts.map +1 -1
  83. package/dist/components/index.js +1 -0
  84. package/dist/components/index.js.map +1 -1
  85. package/dist/hooks/useChatStream.d.ts +15 -0
  86. package/dist/hooks/useChatStream.d.ts.map +1 -1
  87. package/dist/hooks/useChatStream.js +13 -1
  88. package/dist/hooks/useChatStream.js.map +1 -1
  89. package/dist/index.d.ts +2 -0
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +5 -0
  92. package/dist/index.js.map +1 -1
  93. package/dist/state/ChatSessionProvider.d.ts +59 -0
  94. package/dist/state/ChatSessionProvider.d.ts.map +1 -0
  95. package/dist/state/ChatSessionProvider.js +71 -0
  96. package/dist/state/ChatSessionProvider.js.map +1 -0
  97. package/dist/state/MockChatStreamTransport.d.ts +54 -0
  98. package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
  99. package/dist/state/MockChatStreamTransport.js +86 -0
  100. package/dist/state/MockChatStreamTransport.js.map +1 -0
  101. package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
  102. package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
  103. package/dist/state/MockChatStreamTransport.stories.js +60 -0
  104. package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
  105. package/dist/state/bridgeLegacySSE.d.ts +42 -0
  106. package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
  107. package/dist/state/bridgeLegacySSE.js +102 -0
  108. package/dist/state/bridgeLegacySSE.js.map +1 -0
  109. package/dist/state/index.d.ts +32 -0
  110. package/dist/state/index.d.ts.map +1 -0
  111. package/dist/state/index.js +24 -0
  112. package/dist/state/index.js.map +1 -0
  113. package/dist/state/parseSSE.d.ts +50 -0
  114. package/dist/state/parseSSE.d.ts.map +1 -0
  115. package/dist/state/parseSSE.js +112 -0
  116. package/dist/state/parseSSE.js.map +1 -0
  117. package/dist/state/useChatComposer.d.ts +51 -0
  118. package/dist/state/useChatComposer.d.ts.map +1 -0
  119. package/dist/state/useChatComposer.js +82 -0
  120. package/dist/state/useChatComposer.js.map +1 -0
  121. package/dist/state/useChatList.d.ts +73 -0
  122. package/dist/state/useChatList.d.ts.map +1 -0
  123. package/dist/state/useChatList.js +161 -0
  124. package/dist/state/useChatList.js.map +1 -0
  125. package/dist/state/useChatSession.d.ts +29 -0
  126. package/dist/state/useChatSession.d.ts.map +1 -0
  127. package/dist/state/useChatSession.js +37 -0
  128. package/dist/state/useChatSession.js.map +1 -0
  129. package/dist/state/useScrollLock.d.ts +35 -0
  130. package/dist/state/useScrollLock.d.ts.map +1 -0
  131. package/dist/state/useScrollLock.js +74 -0
  132. package/dist/state/useScrollLock.js.map +1 -0
  133. package/dist/state/useToolCallStream.d.ts +38 -0
  134. package/dist/state/useToolCallStream.d.ts.map +1 -0
  135. package/dist/state/useToolCallStream.js +74 -0
  136. package/dist/state/useToolCallStream.js.map +1 -0
  137. package/package.json +11 -3
  138. package/src/cards/ActionSegmentCard.tsx +60 -0
  139. package/src/cards/AnalysisDocumentCard.tsx +81 -0
  140. package/src/cards/AskUserQuestionsCard.tsx +354 -0
  141. package/src/cards/CoverageReportCard.tsx +215 -0
  142. package/src/cards/OrchestrationPlanCard.tsx +328 -0
  143. package/src/cards/PlanSelectorCard.tsx +312 -0
  144. package/src/cards/PlanStepsCard.tsx +52 -0
  145. package/src/cards/QuizCard.tsx +309 -0
  146. package/src/cards/ResearchPlanCard.tsx +169 -0
  147. package/src/cards/SearchSourcesCard.tsx +244 -0
  148. package/src/cards/SuggestedRepliesCard.tsx +48 -0
  149. package/src/cards/SummaryMessageCard.tsx +145 -0
  150. package/src/cards/ThinkingProcessCard.tsx +155 -0
  151. package/src/cards/ToolExecutionCard.tsx +152 -0
  152. package/src/cards/cards.test.tsx +492 -0
  153. package/src/cards/icons.tsx +249 -0
  154. package/src/cards/index.ts +53 -0
  155. package/src/cards/types.ts +27 -0
  156. package/src/components/AgentSelector.stories.tsx +113 -0
  157. package/src/components/AgentSelector.test.tsx +70 -0
  158. package/src/components/AgentSelector.tsx +147 -0
  159. package/src/components/ChatPanel.test.tsx +130 -0
  160. package/src/components/ChatPanel.tsx +612 -138
  161. package/src/components/index.ts +13 -1
  162. package/src/hooks/useChatStream.test.ts +78 -0
  163. package/src/hooks/useChatStream.ts +32 -1
  164. package/src/index.ts +43 -0
  165. package/src/state/ChatSessionProvider.test.tsx +85 -0
  166. package/src/state/ChatSessionProvider.tsx +98 -0
  167. package/src/state/MockChatStreamTransport.stories.tsx +95 -0
  168. package/src/state/MockChatStreamTransport.test.ts +163 -0
  169. package/src/state/MockChatStreamTransport.ts +123 -0
  170. package/src/state/bridgeLegacySSE.test.ts +134 -0
  171. package/src/state/bridgeLegacySSE.ts +134 -0
  172. package/src/state/index.ts +82 -0
  173. package/src/state/parseSSE.test.ts +123 -0
  174. package/src/state/parseSSE.ts +123 -0
  175. package/src/state/useChatComposer.test.ts +145 -0
  176. package/src/state/useChatComposer.ts +117 -0
  177. package/src/state/useChatList.test.ts +135 -0
  178. package/src/state/useChatList.ts +220 -0
  179. package/src/state/useChatSession.test.ts +61 -0
  180. package/src/state/useChatSession.ts +64 -0
  181. package/src/state/useScrollLock.test.ts +99 -0
  182. package/src/state/useScrollLock.ts +105 -0
  183. package/src/state/useToolCallStream.test.ts +86 -0
  184. package/src/state/useToolCallStream.ts +110 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * `<AskUserQuestionsCard />`
3
+ *
4
+ * Mid-run interrupt: the agent has paused and is asking the user to answer a
5
+ * structured set of questions before continuing. Supports select / text /
6
+ * password question types with optional multi-select and an opt-in "custom
7
+ * text" escape per question.
8
+ *
9
+ * Submission is controlled: the parent passes `onSubmit(answers)` and may
10
+ * re-emit the same card with `payload.answers` filled in to flip the UI into
11
+ * read-only review mode.
12
+ *
13
+ * Features:
14
+ * - `allowCustomText`: each `select` question gets a "自定义" pill that
15
+ * flips it into a free-form input (deeppath relies on this for
16
+ * "the option I want is not listed").
17
+ * - `onAutoContinue` + `autoContinueLabel`: surfaces an escape button in the
18
+ * intro row ("交给我决定") that delegates the decision back to the agent.
19
+ * - `requireAllAnswered`: disables the submit button until every question
20
+ * has a non-empty answer.
21
+ * - Localisable strings: `submitLabel`, `multiSelectLabel`, `customLabel`,
22
+ * `bottomHint`.
23
+ */
24
+ import * as React from 'react';
25
+ import type { AskUserQuestionsPayload } from '@steerable/agent-protocol';
26
+ import {
27
+ MessageQuestionIcon,
28
+ CheckIcon,
29
+ LoaderIcon,
30
+ PencilIcon,
31
+ } from './icons.js';
32
+
33
+ type AnswerValue = string | string[];
34
+ type Question = AskUserQuestionsPayload['questions'][number];
35
+
36
+ export interface AskUserQuestionsCardProps {
37
+ payload: AskUserQuestionsPayload;
38
+ isComplete?: boolean;
39
+ onSubmit?: (answers: Record<string, AnswerValue>) => void;
40
+ onAutoContinue?: () => void;
41
+ className?: string;
42
+ allowCustomText?: boolean;
43
+ requireAllAnswered?: boolean;
44
+ submitLabel?: React.ReactNode;
45
+ multiSelectLabel?: React.ReactNode;
46
+ customLabel?: React.ReactNode;
47
+ autoContinueLabel?: React.ReactNode;
48
+ bottomHint?: React.ReactNode;
49
+ }
50
+
51
+ function emptyAnswerFor(q: Question): AnswerValue {
52
+ if (q.multiSelect) return [];
53
+ return '';
54
+ }
55
+
56
+ function isInputOnly(q: Question): boolean {
57
+ return q.type === 'text' || q.type === 'password';
58
+ }
59
+
60
+ export const AskUserQuestionsCard: React.FC<AskUserQuestionsCardProps> = ({
61
+ payload,
62
+ isComplete,
63
+ onSubmit,
64
+ onAutoContinue,
65
+ className,
66
+ allowCustomText = false,
67
+ requireAllAnswered = false,
68
+ submitLabel = '提交',
69
+ multiSelectLabel = '(可多选)',
70
+ customLabel = '自定义',
71
+ autoContinueLabel = '交给我决定',
72
+ bottomHint,
73
+ }) => {
74
+ const isReadOnly = Boolean(isComplete || payload.answers);
75
+
76
+ const initial = React.useMemo(() => {
77
+ const seed: Record<string, AnswerValue> = {};
78
+ const custom: Record<string, boolean> = {};
79
+ const text: Record<string, string> = {};
80
+ for (const q of payload.questions ?? []) {
81
+ const fromBackend = payload.answers?.[q.id];
82
+ if (fromBackend === undefined) {
83
+ seed[q.id] = emptyAnswerFor(q);
84
+ continue;
85
+ }
86
+ if (isInputOnly(q)) {
87
+ text[q.id] = typeof fromBackend === 'string' ? fromBackend : '';
88
+ seed[q.id] = fromBackend;
89
+ continue;
90
+ }
91
+ const options = q.options ?? [];
92
+ if (Array.isArray(fromBackend)) {
93
+ const allInOptions = fromBackend.every((a) => options.includes(a));
94
+ if (allInOptions) {
95
+ seed[q.id] = fromBackend;
96
+ } else {
97
+ custom[q.id] = true;
98
+ text[q.id] = fromBackend.join('、');
99
+ seed[q.id] = fromBackend;
100
+ }
101
+ } else {
102
+ if (options.includes(fromBackend)) {
103
+ seed[q.id] = fromBackend;
104
+ } else {
105
+ custom[q.id] = true;
106
+ text[q.id] = fromBackend;
107
+ seed[q.id] = fromBackend;
108
+ }
109
+ }
110
+ }
111
+ return { seed, custom, text };
112
+ }, [payload.questions, payload.answers]);
113
+
114
+ const [draft, setDraft] = React.useState<Record<string, AnswerValue>>(initial.seed);
115
+ const [customMode, setCustomMode] = React.useState<Record<string, boolean>>(initial.custom);
116
+ const [customText, setCustomText] = React.useState<Record<string, string>>(initial.text);
117
+ const [submitting, setSubmitting] = React.useState(false);
118
+
119
+ React.useEffect(() => {
120
+ setDraft(initial.seed);
121
+ setCustomMode(initial.custom);
122
+ setCustomText(initial.text);
123
+ }, [initial]);
124
+
125
+ const toggleMulti = (qId: string, opt: string) =>
126
+ setDraft((p) => {
127
+ const cur = (p[qId] ?? []) as string[];
128
+ return { ...p, [qId]: cur.includes(opt) ? cur.filter((v) => v !== opt) : [...cur, opt] };
129
+ });
130
+ const setSingle = (qId: string, value: string) => setDraft((p) => ({ ...p, [qId]: value }));
131
+ const enterCustom = (qId: string) => {
132
+ setCustomMode((p) => ({ ...p, [qId]: true }));
133
+ setDraft((p) => {
134
+ const next = { ...p };
135
+ delete next[qId];
136
+ return next;
137
+ });
138
+ };
139
+ const cancelCustom = (qId: string) => {
140
+ setCustomMode((p) => ({ ...p, [qId]: false }));
141
+ setCustomText((p) => {
142
+ const next = { ...p };
143
+ delete next[qId];
144
+ return next;
145
+ });
146
+ };
147
+
148
+ const answerForQuestion = React.useCallback(
149
+ (q: Question): string => {
150
+ if (isInputOnly(q)) return (customText[q.id] || '').trim();
151
+ if (allowCustomText && customMode[q.id]) return (customText[q.id] || '').trim();
152
+ const val = draft[q.id];
153
+ if (Array.isArray(val)) return val.length > 0 ? val.join('、') : '';
154
+ return (val as string) || '';
155
+ },
156
+ [allowCustomText, customMode, customText, draft],
157
+ );
158
+
159
+ const allAnswered = React.useMemo(
160
+ () => (payload.questions ?? []).every((q) => answerForQuestion(q).length > 0),
161
+ [payload.questions, answerForQuestion],
162
+ );
163
+
164
+ const handleSubmit = async () => {
165
+ if (!onSubmit) return;
166
+ if (requireAllAnswered && !allAnswered) return;
167
+ setSubmitting(true);
168
+ try {
169
+ const out: Record<string, AnswerValue> = {};
170
+ for (const q of payload.questions ?? []) {
171
+ if (isInputOnly(q) || (allowCustomText && customMode[q.id])) {
172
+ out[q.id] = (customText[q.id] || '').trim();
173
+ } else {
174
+ out[q.id] = draft[q.id] ?? emptyAnswerFor(q);
175
+ }
176
+ }
177
+ onSubmit(out);
178
+ } finally {
179
+ setSubmitting(false);
180
+ }
181
+ };
182
+
183
+ return (
184
+ <div
185
+ className={[
186
+ 'steerable-ask-user overflow-hidden rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-sm text-[var(--agent-foreground,#111827)]',
187
+ className,
188
+ ].filter(Boolean).join(' ')}
189
+ >
190
+ <div className="space-y-4 p-3">
191
+ <div className="flex items-start justify-between gap-3">
192
+ <div className="flex flex-1 items-start gap-2">
193
+ {!payload.intro && (
194
+ <MessageQuestionIcon size={14} className="mt-0.5 text-[var(--agent-muted-foreground,#6b7280)]" />
195
+ )}
196
+ {payload.intro ? (
197
+ <p className="flex-1 text-sm">{payload.intro}</p>
198
+ ) : (
199
+ <span className="font-medium">需要你的输入</span>
200
+ )}
201
+ </div>
202
+ {!isReadOnly && onAutoContinue && (
203
+ <button
204
+ type="button"
205
+ onClick={onAutoContinue}
206
+ className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] px-3 py-1 text-xs font-medium text-[var(--agent-foreground,#111827)] transition-all duration-200 hover:bg-[var(--agent-muted,#f3f4f6)]"
207
+ >
208
+ <CheckIcon size={12} />
209
+ {autoContinueLabel}
210
+ </button>
211
+ )}
212
+ </div>
213
+
214
+ <ol className="space-y-3">
215
+ {(payload.questions ?? []).map((q, idx) => {
216
+ const value = draft[q.id];
217
+ const inputOnly = isInputOnly(q);
218
+ const isCustom = allowCustomText && customMode[q.id];
219
+
220
+ return (
221
+ <li key={q.id} className="space-y-2">
222
+ <p className="text-sm font-medium">
223
+ {idx + 1}. {q.text}
224
+ {!inputOnly && q.multiSelect && (
225
+ <span className="ml-1.5 text-xs font-normal text-[var(--agent-muted-foreground,#6b7280)]">
226
+ {multiSelectLabel}
227
+ </span>
228
+ )}
229
+ </p>
230
+
231
+ {inputOnly && (
232
+ <input
233
+ type={q.type === 'password' ? 'password' : 'text'}
234
+ disabled={isReadOnly}
235
+ placeholder={q.placeholder || (q.type === 'password' ? '输入密码' : '输入你的回答...')}
236
+ value={customText[q.id] || ''}
237
+ onChange={(e) => setCustomText((p) => ({ ...p, [q.id]: e.target.value }))}
238
+ className="w-full rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-background,#fff)] px-3 py-1.5 text-xs text-[var(--agent-foreground,#111827)] outline-none placeholder:text-[var(--agent-muted-foreground,#6b7280)] focus:border-[var(--agent-primary,#18181b)] focus:ring-1 focus:ring-[var(--agent-primary,#18181b)] disabled:cursor-not-allowed disabled:opacity-60"
239
+ />
240
+ )}
241
+
242
+ {!inputOnly && (
243
+ <>
244
+ <div className="flex flex-wrap gap-2">
245
+ {(q.options ?? []).map((opt) => {
246
+ const selected =
247
+ !isCustom &&
248
+ (q.multiSelect
249
+ ? (Array.isArray(value) ? value : []).includes(opt)
250
+ : value === opt);
251
+ return (
252
+ <button
253
+ key={opt}
254
+ type="button"
255
+ disabled={isReadOnly}
256
+ onClick={() =>
257
+ q.multiSelect ? toggleMulti(q.id, opt) : setSingle(q.id, opt)
258
+ }
259
+ className={[
260
+ 'rounded-full border px-3 py-1.5 text-xs font-medium transition-all duration-200',
261
+ selected
262
+ ? 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-muted,#f3f4f6)] text-[var(--agent-foreground,#111827)] ring-1 ring-[var(--agent-border,#e5e7eb)]'
263
+ : 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-[var(--agent-muted-foreground,#6b7280)] hover:bg-[var(--agent-muted,#f3f4f6)] hover:text-[var(--agent-foreground,#111827)]',
264
+ isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
265
+ ].join(' ')}
266
+ >
267
+ {q.multiSelect && selected && (
268
+ <CheckIcon size={10} className="-ml-0.5 mr-1 inline" />
269
+ )}
270
+ {opt}
271
+ </button>
272
+ );
273
+ })}
274
+
275
+ {allowCustomText && !isCustom && (
276
+ <button
277
+ type="button"
278
+ disabled={isReadOnly}
279
+ onClick={() => enterCustom(q.id)}
280
+ className={[
281
+ 'rounded-full border border-dashed border-[var(--agent-border,#e5e7eb)] px-3 py-1.5 text-xs font-medium text-[var(--agent-muted-foreground,#6b7280)] transition-all duration-200 hover:bg-[var(--agent-muted,#f3f4f6)] hover:text-[var(--agent-foreground,#111827)]',
282
+ isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
283
+ ].join(' ')}
284
+ >
285
+ <span className="inline-flex items-center gap-1">
286
+ <PencilIcon size={12} />
287
+ {customLabel}
288
+ </span>
289
+ </button>
290
+ )}
291
+ </div>
292
+
293
+ {isCustom && (
294
+ <div className="flex items-center gap-2">
295
+ <input
296
+ type="text"
297
+ disabled={isReadOnly}
298
+ placeholder="输入你的回答..."
299
+ value={customText[q.id] || ''}
300
+ onChange={(e) =>
301
+ setCustomText((p) => ({ ...p, [q.id]: e.target.value }))
302
+ }
303
+ className="flex-1 rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-background,#fff)] px-3 py-1.5 text-xs text-[var(--agent-foreground,#111827)] outline-none placeholder:text-[var(--agent-muted-foreground,#6b7280)] focus:border-[var(--agent-primary,#18181b)] focus:ring-1 focus:ring-[var(--agent-primary,#18181b)] disabled:cursor-not-allowed disabled:opacity-60"
304
+ />
305
+ <button
306
+ type="button"
307
+ disabled={isReadOnly}
308
+ onClick={() => cancelCustom(q.id)}
309
+ className="text-xs text-[var(--agent-muted-foreground,#6b7280)] transition-colors duration-200 hover:text-[var(--agent-foreground,#111827)]"
310
+ >
311
+ 取消
312
+ </button>
313
+ </div>
314
+ )}
315
+ </>
316
+ )}
317
+ </li>
318
+ );
319
+ })}
320
+ </ol>
321
+
322
+ {payload.outro && (
323
+ <p className="text-xs text-[var(--agent-muted-foreground,#6b7280)] whitespace-pre-line">
324
+ {payload.outro}
325
+ </p>
326
+ )}
327
+
328
+ {!isReadOnly && onSubmit && (
329
+ <div className="flex items-center justify-between pt-1">
330
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
331
+ {bottomHint}
332
+ </span>
333
+ <button
334
+ type="button"
335
+ onClick={handleSubmit}
336
+ disabled={submitting || (requireAllAnswered && !allAnswered)}
337
+ className={[
338
+ 'inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-xs font-medium transition-all duration-200',
339
+ submitting || (requireAllAnswered && !allAnswered)
340
+ ? 'cursor-not-allowed bg-[var(--agent-muted,#f3f4f6)] text-[var(--agent-muted-foreground,#6b7280)]'
341
+ : 'bg-[var(--agent-primary,#18181b)] text-[var(--agent-primary-foreground,#fff)] shadow-sm hover:opacity-90',
342
+ ].join(' ')}
343
+ >
344
+ {submitting ? <LoaderIcon size={10} className="animate-spin" /> : <CheckIcon size={12} />}
345
+ {submitLabel}
346
+ </button>
347
+ </div>
348
+ )}
349
+ </div>
350
+ </div>
351
+ );
352
+ };
353
+
354
+ export default AskUserQuestionsCard;
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `<CoverageReportCard />`
3
+ *
4
+ * Renders mastery / coverage of a knowledge graph: overall numbers, section
5
+ * progress bars, and a list of weak knowledge points with recommendations.
6
+ * Offers a "practice weak points" action (optionally with multi-select) when
7
+ * `payload.actions.allowRemediateQuiz` is set.
8
+ *
9
+ * Configurable bits:
10
+ * - `progressMetric` ('mastery' default, or 'coverage') controls which value
11
+ * fills the per-section progress bar.
12
+ * - `allowWeakPointSelection` turns the weak-points list into checkboxes;
13
+ * `onRemediate` then receives the explicit selected ids (or all ids if
14
+ * nothing is checked).
15
+ * - `renderSectionStats(section)` slot lets consumers attach an info line
16
+ * under each progress bar (e.g. "已学 5/12 · 已测 3 · 已掌握 2").
17
+ * - `remediateDisabled` + `remediateLabel` + `remediateHint` let the host
18
+ * surface submission state (e.g. "已提交补救请求").
19
+ * - `emptyWeakPointsLabel` shows a positive empty state when there are no
20
+ * weak points to display.
21
+ */
22
+ import * as React from 'react';
23
+ import type { CoverageReportPayload } from '@steerable/agent-protocol';
24
+ import { ChartIcon, CheckIcon, AlertIcon } from './icons.js';
25
+ import { asReactNode, type RenderSlotResult } from './types.js';
26
+
27
+ type Section = NonNullable<CoverageReportPayload['sections']>[number];
28
+
29
+ export interface CoverageReportCardProps {
30
+ payload: CoverageReportPayload;
31
+ isComplete?: boolean;
32
+ onRemediate?: (selectedIds: string[]) => void;
33
+ className?: string;
34
+ progressMetric?: 'coverage' | 'mastery';
35
+ allowWeakPointSelection?: boolean;
36
+ remediateDisabled?: boolean;
37
+ remediateLabel?: string;
38
+ remediateHint?: React.ReactNode;
39
+ emptyWeakPointsLabel?: React.ReactNode;
40
+ renderSectionStats?: (section: Section) => RenderSlotResult;
41
+ }
42
+
43
+ function pct(v: number | null | undefined): string {
44
+ const safe = Number.isFinite(v ?? NaN) ? (v as number) : 0;
45
+ const c = Math.max(0, Math.min(1, safe));
46
+ return `${Math.round(c * 100)}%`;
47
+ }
48
+
49
+ export const CoverageReportCard: React.FC<CoverageReportCardProps> = ({
50
+ payload,
51
+ isComplete,
52
+ onRemediate,
53
+ className,
54
+ progressMetric = 'mastery',
55
+ allowWeakPointSelection = false,
56
+ remediateDisabled = false,
57
+ remediateLabel,
58
+ remediateHint,
59
+ emptyWeakPointsLabel,
60
+ renderSectionStats,
61
+ }) => {
62
+ const sections = payload.sections ?? [];
63
+ const weakPoints = payload.weakPoints ?? [];
64
+
65
+ const [selected, setSelected] = React.useState<string[]>([]);
66
+ const toggle = (id: string) =>
67
+ setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
68
+
69
+ const handleClick = () => {
70
+ if (!onRemediate || remediateDisabled) return;
71
+ const ids =
72
+ allowWeakPointSelection && selected.length > 0
73
+ ? selected
74
+ : weakPoints.map((wp) => wp.id);
75
+ onRemediate(ids);
76
+ };
77
+
78
+ return (
79
+ <div
80
+ className={[
81
+ 'steerable-coverage-report rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-sm text-[var(--agent-foreground,#111827)]',
82
+ className,
83
+ ].filter(Boolean).join(' ')}
84
+ >
85
+ <div className="space-y-3 p-3">
86
+ <div className="flex items-center gap-2">
87
+ <ChartIcon size={14} className="text-blue-500" />
88
+ <span className="font-medium">{payload.title}</span>
89
+ {isComplete && <CheckIcon size={12} className="text-emerald-600" />}
90
+ </div>
91
+
92
+ <div className="grid grid-cols-2 gap-2">
93
+ <div className="rounded-md border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-background,#fff)] p-2">
94
+ <p className="text-[11px] text-[var(--agent-muted-foreground,#6b7280)]">整体覆盖率</p>
95
+ <p className="text-base font-semibold">{pct(payload.overallCoverage)}</p>
96
+ </div>
97
+ <div className="rounded-md border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-background,#fff)] p-2">
98
+ <p className="text-[11px] text-[var(--agent-muted-foreground,#6b7280)]">整体掌握率</p>
99
+ <p className="text-base font-semibold">{pct(payload.overallMastery)}</p>
100
+ </div>
101
+ </div>
102
+
103
+ {payload.summary && (
104
+ <p className="text-xs text-[var(--agent-muted-foreground,#6b7280)] whitespace-pre-line">
105
+ {payload.summary}
106
+ </p>
107
+ )}
108
+
109
+ <div className="space-y-2">
110
+ {sections.map((section) => {
111
+ const fill = progressMetric === 'coverage' ? section.coverage : section.mastery;
112
+ return (
113
+ <div
114
+ key={section.id}
115
+ className="rounded-md border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-background,#fff)] p-2"
116
+ >
117
+ <div className="mb-1 flex items-center justify-between">
118
+ <span className="text-sm font-medium">{section.name}</span>
119
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
120
+ 覆盖 {pct(section.coverage)} · 掌握 {pct(section.mastery)}
121
+ </span>
122
+ </div>
123
+ <div className="h-2 w-full overflow-hidden rounded-full bg-[var(--agent-muted,#f3f4f6)]">
124
+ <div
125
+ className={
126
+ progressMetric === 'coverage'
127
+ ? 'h-full bg-[var(--agent-primary,#18181b)] transition-all duration-200'
128
+ : 'h-full bg-emerald-500 transition-all duration-200'
129
+ }
130
+ style={{ width: pct(fill) }}
131
+ aria-hidden
132
+ />
133
+ </div>
134
+ {renderSectionStats && (
135
+ <div className="mt-1 text-[11px] text-[var(--agent-muted-foreground,#6b7280)]">
136
+ {asReactNode(renderSectionStats(section))}
137
+ </div>
138
+ )}
139
+ </div>
140
+ );
141
+ })}
142
+ </div>
143
+
144
+ <div className="space-y-2 rounded-md border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-background,#fff)] p-2">
145
+ <div className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400">
146
+ <AlertIcon size={12} />
147
+ 薄弱知识点
148
+ </div>
149
+ {weakPoints.length === 0 ? (
150
+ emptyWeakPointsLabel ? (
151
+ <div className="inline-flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
152
+ <CheckIcon size={12} />
153
+ {emptyWeakPointsLabel}
154
+ </div>
155
+ ) : null
156
+ ) : allowWeakPointSelection ? (
157
+ <div className="space-y-1.5">
158
+ {weakPoints.map((point) => (
159
+ <label key={point.id} className="flex cursor-pointer items-start gap-2">
160
+ <input
161
+ type="checkbox"
162
+ className="mt-0.5 h-4 w-4"
163
+ checked={selected.includes(point.id)}
164
+ onChange={() => toggle(point.id)}
165
+ disabled={remediateDisabled}
166
+ />
167
+ <div className="min-w-0 text-xs">
168
+ <p className="font-medium">
169
+ {point.name}
170
+ {point.sectionName ? ` · ${point.sectionName}` : ''}
171
+ </p>
172
+ <p className="text-[var(--agent-muted-foreground,#6b7280)]">
173
+ 正确率 {pct(point.accuracy)} · {point.recommendation}
174
+ </p>
175
+ </div>
176
+ </label>
177
+ ))}
178
+ </div>
179
+ ) : (
180
+ <ul className="space-y-1 text-xs">
181
+ {weakPoints.map((wp) => (
182
+ <li key={wp.id}>
183
+ <span className="font-medium">{wp.name}</span>
184
+ {wp.sectionName && (
185
+ <span className="ml-1 text-[var(--agent-muted-foreground,#6b7280)]">· {wp.sectionName}</span>
186
+ )}
187
+ <span className="ml-1 text-[var(--agent-muted-foreground,#6b7280)]">· 正确率 {pct(wp.accuracy)}</span>
188
+ <p className="text-[var(--agent-muted-foreground,#6b7280)]">{wp.recommendation}</p>
189
+ </li>
190
+ ))}
191
+ </ul>
192
+ )}
193
+ </div>
194
+
195
+ {payload.actions?.allowRemediateQuiz && onRemediate && weakPoints.length > 0 && (
196
+ <div className="flex items-center justify-between pt-1">
197
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
198
+ {remediateHint}
199
+ </span>
200
+ <button
201
+ type="button"
202
+ onClick={handleClick}
203
+ disabled={remediateDisabled}
204
+ className="inline-flex h-[28px] items-center rounded-full bg-[var(--agent-primary,#18181b)] px-3 text-xs font-medium text-[var(--agent-primary-foreground,#fff)] transition-all duration-200 hover:opacity-90 disabled:opacity-50"
205
+ >
206
+ {remediateLabel || payload.actions.remediateActionLabel || '为薄弱点出题'}
207
+ </button>
208
+ </div>
209
+ )}
210
+ </div>
211
+ </div>
212
+ );
213
+ };
214
+
215
+ export default CoverageReportCard;