@steerable/agent-ui 0.2.3 → 0.2.4

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 (178) 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/index.d.ts +2 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +5 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/state/ChatSessionProvider.d.ts +59 -0
  90. package/dist/state/ChatSessionProvider.d.ts.map +1 -0
  91. package/dist/state/ChatSessionProvider.js +71 -0
  92. package/dist/state/ChatSessionProvider.js.map +1 -0
  93. package/dist/state/MockChatStreamTransport.d.ts +54 -0
  94. package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
  95. package/dist/state/MockChatStreamTransport.js +86 -0
  96. package/dist/state/MockChatStreamTransport.js.map +1 -0
  97. package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
  98. package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
  99. package/dist/state/MockChatStreamTransport.stories.js +60 -0
  100. package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
  101. package/dist/state/bridgeLegacySSE.d.ts +42 -0
  102. package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
  103. package/dist/state/bridgeLegacySSE.js +102 -0
  104. package/dist/state/bridgeLegacySSE.js.map +1 -0
  105. package/dist/state/index.d.ts +32 -0
  106. package/dist/state/index.d.ts.map +1 -0
  107. package/dist/state/index.js +24 -0
  108. package/dist/state/index.js.map +1 -0
  109. package/dist/state/parseSSE.d.ts +50 -0
  110. package/dist/state/parseSSE.d.ts.map +1 -0
  111. package/dist/state/parseSSE.js +112 -0
  112. package/dist/state/parseSSE.js.map +1 -0
  113. package/dist/state/useChatComposer.d.ts +51 -0
  114. package/dist/state/useChatComposer.d.ts.map +1 -0
  115. package/dist/state/useChatComposer.js +82 -0
  116. package/dist/state/useChatComposer.js.map +1 -0
  117. package/dist/state/useChatList.d.ts +71 -0
  118. package/dist/state/useChatList.d.ts.map +1 -0
  119. package/dist/state/useChatList.js +158 -0
  120. package/dist/state/useChatList.js.map +1 -0
  121. package/dist/state/useChatSession.d.ts +29 -0
  122. package/dist/state/useChatSession.d.ts.map +1 -0
  123. package/dist/state/useChatSession.js +37 -0
  124. package/dist/state/useChatSession.js.map +1 -0
  125. package/dist/state/useScrollLock.d.ts +35 -0
  126. package/dist/state/useScrollLock.d.ts.map +1 -0
  127. package/dist/state/useScrollLock.js +74 -0
  128. package/dist/state/useScrollLock.js.map +1 -0
  129. package/dist/state/useToolCallStream.d.ts +38 -0
  130. package/dist/state/useToolCallStream.d.ts.map +1 -0
  131. package/dist/state/useToolCallStream.js +74 -0
  132. package/dist/state/useToolCallStream.js.map +1 -0
  133. package/package.json +10 -2
  134. package/src/cards/ActionSegmentCard.tsx +60 -0
  135. package/src/cards/AnalysisDocumentCard.tsx +81 -0
  136. package/src/cards/AskUserQuestionsCard.tsx +354 -0
  137. package/src/cards/CoverageReportCard.tsx +215 -0
  138. package/src/cards/OrchestrationPlanCard.tsx +328 -0
  139. package/src/cards/PlanSelectorCard.tsx +312 -0
  140. package/src/cards/PlanStepsCard.tsx +52 -0
  141. package/src/cards/QuizCard.tsx +309 -0
  142. package/src/cards/ResearchPlanCard.tsx +169 -0
  143. package/src/cards/SearchSourcesCard.tsx +244 -0
  144. package/src/cards/SuggestedRepliesCard.tsx +48 -0
  145. package/src/cards/SummaryMessageCard.tsx +145 -0
  146. package/src/cards/ThinkingProcessCard.tsx +155 -0
  147. package/src/cards/ToolExecutionCard.tsx +152 -0
  148. package/src/cards/cards.test.tsx +492 -0
  149. package/src/cards/icons.tsx +249 -0
  150. package/src/cards/index.ts +53 -0
  151. package/src/cards/types.ts +27 -0
  152. package/src/components/AgentSelector.stories.tsx +113 -0
  153. package/src/components/AgentSelector.test.tsx +70 -0
  154. package/src/components/AgentSelector.tsx +147 -0
  155. package/src/components/ChatPanel.test.tsx +130 -0
  156. package/src/components/ChatPanel.tsx +612 -138
  157. package/src/components/index.ts +13 -1
  158. package/src/index.ts +43 -0
  159. package/src/state/ChatSessionProvider.test.tsx +85 -0
  160. package/src/state/ChatSessionProvider.tsx +98 -0
  161. package/src/state/MockChatStreamTransport.stories.tsx +95 -0
  162. package/src/state/MockChatStreamTransport.test.ts +163 -0
  163. package/src/state/MockChatStreamTransport.ts +123 -0
  164. package/src/state/bridgeLegacySSE.test.ts +134 -0
  165. package/src/state/bridgeLegacySSE.ts +134 -0
  166. package/src/state/index.ts +82 -0
  167. package/src/state/parseSSE.test.ts +123 -0
  168. package/src/state/parseSSE.ts +123 -0
  169. package/src/state/useChatComposer.test.ts +145 -0
  170. package/src/state/useChatComposer.ts +117 -0
  171. package/src/state/useChatList.test.ts +135 -0
  172. package/src/state/useChatList.ts +217 -0
  173. package/src/state/useChatSession.test.ts +61 -0
  174. package/src/state/useChatSession.ts +64 -0
  175. package/src/state/useScrollLock.test.ts +99 -0
  176. package/src/state/useScrollLock.ts +105 -0
  177. package/src/state/useToolCallStream.test.ts +86 -0
  178. package/src/state/useToolCallStream.ts +110 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * `<QuizCard />`
3
+ *
4
+ * Renders a multi-question quiz: choice / fill / judge / short_answer types.
5
+ * The card manages an internal draft, calls `onSubmit(answers)` once, and
6
+ * switches to read-only mode if `isComplete` is true (or the backend re-emits
7
+ * with `payload.submittedAnswers` filled in).
8
+ *
9
+ * Variants:
10
+ * - `choiceVariant`: 'pills' (default; chip-style buttons) | 'radio' (native
11
+ * radio/checkbox inputs in a vertical list).
12
+ * - `questionVariant`: 'flat' (default) | 'card' (each question gets its own
13
+ * bordered inner card).
14
+ *
15
+ * Slots:
16
+ * - `judgeLabels`: tuple for "true"/"false" buttons. Default `['对', '错']`.
17
+ * - `submitHint`, `submittedMessage`, `submittingLabel`: localisable
18
+ * status strings.
19
+ * - `renderQuestionMeta(question)`: append metadata under the stem (e.g.
20
+ * difficulty + points badges).
21
+ */
22
+ import * as React from 'react';
23
+ import type { QuizPayload } from '@steerable/agent-protocol';
24
+ import { ClipboardIcon, LoaderIcon, CheckIcon } from './icons.js';
25
+ import { asReactNode, type RenderSlotResult } from './types.js';
26
+
27
+ type AnswerValue = string | string[];
28
+ type Question = QuizPayload['questions'][number];
29
+
30
+ export type QuizChoiceVariant = 'pills' | 'radio';
31
+ export type QuizQuestionVariant = 'flat' | 'card';
32
+
33
+ export interface QuizCardProps {
34
+ payload: QuizPayload;
35
+ isComplete?: boolean;
36
+ onSubmit?: (answers: Record<string, AnswerValue>) => void;
37
+ className?: string;
38
+ choiceVariant?: QuizChoiceVariant;
39
+ questionVariant?: QuizQuestionVariant;
40
+ judgeLabels?: [string, string];
41
+ submitHint?: React.ReactNode;
42
+ submittedMessage?: React.ReactNode;
43
+ submittingLabel?: React.ReactNode;
44
+ renderQuestionMeta?: (question: Question) => RenderSlotResult;
45
+ }
46
+
47
+ function emptyAnswer(q: Question): AnswerValue {
48
+ if (q.type === 'choice' && q.allowMultiple) return [];
49
+ return '';
50
+ }
51
+
52
+ export const QuizCard: React.FC<QuizCardProps> = ({
53
+ payload,
54
+ isComplete,
55
+ onSubmit,
56
+ className,
57
+ choiceVariant = 'pills',
58
+ questionVariant = 'flat',
59
+ judgeLabels = ['对', '错'],
60
+ submitHint,
61
+ submittedMessage,
62
+ submittingLabel = '提交中...',
63
+ renderQuestionMeta,
64
+ }) => {
65
+ const isReadOnly = Boolean(
66
+ isComplete ||
67
+ (payload.submittedAnswers && Object.keys(payload.submittedAnswers).length > 0),
68
+ );
69
+ const initial = React.useMemo(() => {
70
+ const seed: Record<string, AnswerValue> = {};
71
+ for (const q of payload.questions ?? []) {
72
+ seed[q.id] = (payload.submittedAnswers?.[q.id] ?? emptyAnswer(q)) as AnswerValue;
73
+ }
74
+ return seed;
75
+ }, [payload.questions, payload.submittedAnswers]);
76
+ const [draft, setDraft] = React.useState(initial);
77
+ const [submitting, setSubmitting] = React.useState(false);
78
+
79
+ React.useEffect(() => {
80
+ setDraft(initial);
81
+ }, [initial]);
82
+
83
+ const setSingle = (qId: string, value: string) => setDraft((p) => ({ ...p, [qId]: value }));
84
+ const toggleMulti = (qId: string, opt: string) =>
85
+ setDraft((p) => {
86
+ const cur = (p[qId] ?? []) as string[];
87
+ return { ...p, [qId]: cur.includes(opt) ? cur.filter((v) => v !== opt) : [...cur, opt] };
88
+ });
89
+
90
+ const handleSubmit = async () => {
91
+ if (!onSubmit) return;
92
+ setSubmitting(true);
93
+ try {
94
+ onSubmit(draft);
95
+ } finally {
96
+ setSubmitting(false);
97
+ }
98
+ };
99
+
100
+ const renderChoice = (q: Question, value: AnswerValue) => {
101
+ const options = q.options ?? [];
102
+ if (choiceVariant === 'radio') {
103
+ return (
104
+ <div className="space-y-1.5">
105
+ {options.map((opt) => {
106
+ const checked = Array.isArray(value) ? value.includes(opt) : value === opt;
107
+ return (
108
+ <label key={opt} className="flex cursor-pointer items-start gap-2 text-sm">
109
+ <input
110
+ type={q.allowMultiple ? 'checkbox' : 'radio'}
111
+ name={q.id}
112
+ checked={checked}
113
+ disabled={isReadOnly}
114
+ className="mt-[3px] h-4 w-4 shrink-0"
115
+ onChange={() => (q.allowMultiple ? toggleMulti(q.id, opt) : setSingle(q.id, opt))}
116
+ />
117
+ <span className="min-w-0 break-words leading-relaxed">{opt}</span>
118
+ </label>
119
+ );
120
+ })}
121
+ </div>
122
+ );
123
+ }
124
+ return (
125
+ <div className="flex flex-wrap gap-2">
126
+ {options.map((opt) => {
127
+ const selected = q.allowMultiple
128
+ ? (Array.isArray(value) ? value : []).includes(opt)
129
+ : value === opt;
130
+ return (
131
+ <button
132
+ key={opt}
133
+ type="button"
134
+ disabled={isReadOnly}
135
+ onClick={() => (q.allowMultiple ? toggleMulti(q.id, opt) : setSingle(q.id, opt))}
136
+ className={[
137
+ 'rounded-full border px-3 py-1 text-xs transition-colors duration-150',
138
+ selected
139
+ ? 'border-[var(--agent-primary,#18181b)] bg-[var(--agent-primary,#18181b)] text-[var(--agent-primary-foreground,#fff)]'
140
+ : 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] hover:bg-[var(--agent-accent,#f3f4f6)]',
141
+ isReadOnly ? 'cursor-default opacity-80' : '',
142
+ ].filter(Boolean).join(' ')}
143
+ >
144
+ {opt}
145
+ </button>
146
+ );
147
+ })}
148
+ </div>
149
+ );
150
+ };
151
+
152
+ const renderJudge = (q: Question, value: AnswerValue) => {
153
+ if (choiceVariant === 'radio') {
154
+ return (
155
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
156
+ {judgeLabels.map((opt) => (
157
+ <label key={opt} className="flex cursor-pointer items-center gap-2">
158
+ <input
159
+ type="radio"
160
+ name={q.id}
161
+ checked={value === opt}
162
+ disabled={isReadOnly}
163
+ onChange={() => setSingle(q.id, opt)}
164
+ className="h-4 w-4 shrink-0"
165
+ />
166
+ <span>{opt}</span>
167
+ </label>
168
+ ))}
169
+ </div>
170
+ );
171
+ }
172
+ return (
173
+ <div className="flex gap-2">
174
+ {judgeLabels.map((opt) => (
175
+ <button
176
+ key={opt}
177
+ type="button"
178
+ disabled={isReadOnly}
179
+ onClick={() => setSingle(q.id, opt)}
180
+ className={[
181
+ 'rounded-full border px-3 py-1 text-xs transition-colors duration-150',
182
+ value === opt
183
+ ? 'border-[var(--agent-primary,#18181b)] bg-[var(--agent-primary,#18181b)] text-[var(--agent-primary-foreground,#fff)]'
184
+ : 'border-[var(--agent-border,#e5e7eb)] hover:bg-[var(--agent-accent,#f3f4f6)]',
185
+ ].filter(Boolean).join(' ')}
186
+ >
187
+ {opt}
188
+ </button>
189
+ ))}
190
+ </div>
191
+ );
192
+ };
193
+
194
+ const renderTextarea = (q: Question, value: AnswerValue) => (
195
+ <textarea
196
+ rows={q.type === 'short_answer' ? 4 : 2}
197
+ value={typeof value === 'string' ? value : ''}
198
+ disabled={isReadOnly}
199
+ placeholder={q.placeholder ?? '请输入你的答案'}
200
+ onChange={(e) => setSingle(q.id, e.target.value)}
201
+ className="w-full rounded-md border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] px-3 py-2 text-sm text-[var(--agent-foreground,#111827)] outline-none transition-all duration-200 focus:border-[var(--agent-primary,#18181b)]"
202
+ />
203
+ );
204
+
205
+ const defaultMeta = (q: Question) => {
206
+ if (!q.difficulty && !q.points) return null;
207
+ return (
208
+ <>
209
+ {q.difficulty ? <span>难度 {q.difficulty}</span> : null}
210
+ {q.points ? <span>{q.points} 分</span> : null}
211
+ </>
212
+ );
213
+ };
214
+
215
+ return (
216
+ <div
217
+ className={[
218
+ 'steerable-quiz overflow-hidden rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-sm text-[var(--agent-foreground,#111827)]',
219
+ className,
220
+ ].filter(Boolean).join(' ')}
221
+ >
222
+ <div className="space-y-3 p-3">
223
+ <div className="flex items-center gap-2">
224
+ <ClipboardIcon size={14} className="shrink-0 text-blue-500" />
225
+ <span className="break-words font-medium">{payload.title}</span>
226
+ {isReadOnly && <CheckIcon size={12} className="text-emerald-600" />}
227
+ </div>
228
+ {payload.description && (
229
+ <p className="break-words text-xs leading-relaxed text-[var(--agent-muted-foreground,#6b7280)]">
230
+ {payload.description}
231
+ </p>
232
+ )}
233
+
234
+ <div className={questionVariant === 'card' ? 'space-y-3' : 'space-y-4'}>
235
+ {(payload.questions ?? []).map((q, idx) => {
236
+ const value = draft[q.id] ?? emptyAnswer(q);
237
+ const metaNode = renderQuestionMeta
238
+ ? asReactNode(renderQuestionMeta(q))
239
+ : defaultMeta(q);
240
+ const inner = (
241
+ <>
242
+ <div className="mb-2">
243
+ <p className="break-words text-sm font-medium leading-relaxed">
244
+ {idx + 1}. {q.stem}
245
+ </p>
246
+ {metaNode && (
247
+ <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-[var(--agent-muted-foreground,#6b7280)]">
248
+ {metaNode}
249
+ </div>
250
+ )}
251
+ </div>
252
+ {q.type === 'choice' && q.options ? renderChoice(q, value) : null}
253
+ {q.type === 'judge' ? renderJudge(q, value) : null}
254
+ {q.type === 'fill' || q.type === 'short_answer' ? renderTextarea(q, value) : null}
255
+ </>
256
+ );
257
+ return questionVariant === 'card' ? (
258
+ <div
259
+ key={q.id}
260
+ className="rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-background,#fff)] p-3"
261
+ >
262
+ {inner}
263
+ </div>
264
+ ) : (
265
+ <div key={q.id} className="space-y-1.5">
266
+ {inner}
267
+ </div>
268
+ );
269
+ })}
270
+ </div>
271
+
272
+ <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 pt-1">
273
+ {isReadOnly ? (
274
+ <div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
275
+ <CheckIcon size={12} />
276
+ <span className="truncate">{submittedMessage ?? '已提交'}</span>
277
+ </div>
278
+ ) : (
279
+ submitHint && (
280
+ <span className="min-w-0 flex-1 break-words text-xs text-[var(--agent-muted-foreground,#6b7280)]">
281
+ {submitHint}
282
+ </span>
283
+ )
284
+ )}
285
+
286
+ {!isReadOnly && onSubmit && (
287
+ <button
288
+ type="button"
289
+ onClick={handleSubmit}
290
+ disabled={submitting || isReadOnly}
291
+ className="ml-auto inline-flex h-[28px] shrink-0 items-center gap-1 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"
292
+ >
293
+ {submitting ? (
294
+ <>
295
+ <LoaderIcon size={10} className="animate-spin" />
296
+ {submittingLabel}
297
+ </>
298
+ ) : (
299
+ payload.submitActionLabel || '提交'
300
+ )}
301
+ </button>
302
+ )}
303
+ </div>
304
+ </div>
305
+ </div>
306
+ );
307
+ };
308
+
309
+ export default QuizCard;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `<ResearchPlanCard />`
3
+ *
4
+ * Snapshot of a research agent's current sub-question tree, with per-question
5
+ * status badges (searching / strong evidence / conflicted / ...) and the
6
+ * coordinator's decision for the next round.
7
+ *
8
+ * Features:
9
+ * - progress bar (round / totalRounds);
10
+ * - sub-question note rendering when `note` is present;
11
+ * - boxed "next decision" footer with the agent's reasoning.
12
+ */
13
+ import * as React from 'react';
14
+ import type { ResearchPlanPayload } from '@steerable/agent-protocol';
15
+ import { GitBranchIcon, ListChecksIcon } from './icons.js';
16
+
17
+ type Kind = 'fact' | 'compare' | 'conclusion' | 'risk';
18
+ type Status =
19
+ | 'pending'
20
+ | 'searching'
21
+ | 'evidenced_strong'
22
+ | 'evidenced_medium'
23
+ | 'evidenced_weak'
24
+ | 'conflicted'
25
+ | 'exhausted';
26
+
27
+ const KIND_LABELS: Record<Kind, string> = {
28
+ fact: '事实',
29
+ compare: '对比',
30
+ conclusion: '结论',
31
+ risk: '风险',
32
+ };
33
+
34
+ const STATUS_LABELS: Record<Status, string> = {
35
+ pending: '待开始',
36
+ searching: '检索中',
37
+ evidenced_strong: '强证据',
38
+ evidenced_medium: '中证据',
39
+ evidenced_weak: '弱证据',
40
+ conflicted: '证据冲突',
41
+ exhausted: '已穷尽',
42
+ };
43
+
44
+ const STATUS_COLOR: Record<Status, string> = {
45
+ pending: 'bg-[var(--agent-muted,#f3f4f6)] text-[var(--agent-muted-foreground,#6b7280)]',
46
+ searching: 'bg-[var(--agent-muted,#f3f4f6)] text-[var(--agent-muted-foreground,#6b7280)]',
47
+ evidenced_strong: 'bg-green-500/15 text-green-700 dark:text-green-400',
48
+ evidenced_medium: 'bg-blue-500/15 text-blue-700 dark:text-blue-400',
49
+ evidenced_weak: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
50
+ conflicted: 'bg-red-500/15 text-red-700 dark:text-red-400',
51
+ exhausted: 'bg-zinc-500/15 text-zinc-700 dark:text-zinc-400',
52
+ };
53
+
54
+ const DECISION_LABEL: Record<'continue' | 'expand' | 'converge', string> = {
55
+ continue: '继续深挖',
56
+ expand: '横向扩展',
57
+ converge: '进入收敛',
58
+ };
59
+
60
+ export interface ResearchPlanCardProps {
61
+ payload: ResearchPlanPayload;
62
+ /** Total expected rounds (used to compute the progress bar). Defaults to 3. */
63
+ totalRounds?: number;
64
+ className?: string;
65
+ }
66
+
67
+ export const ResearchPlanCard: React.FC<ResearchPlanCardProps> = ({
68
+ payload,
69
+ totalRounds = 3,
70
+ className,
71
+ }) => {
72
+ const subQuestions = payload.subQuestions ?? [];
73
+ const round = Math.max(0, Math.min(totalRounds, payload.round ?? 0));
74
+ const progressPercent = totalRounds > 0
75
+ ? Math.max(0, Math.min(100, (round / totalRounds) * 100))
76
+ : 0;
77
+ const decisionLabel =
78
+ payload.decision &&
79
+ DECISION_LABEL[payload.decision.next as 'continue' | 'expand' | 'converge'];
80
+
81
+ return (
82
+ <div
83
+ className={[
84
+ 'steerable-research-plan rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-sm text-[var(--agent-foreground,#111827)]',
85
+ className,
86
+ ].filter(Boolean).join(' ')}
87
+ >
88
+ <div className="space-y-3 p-3">
89
+ <div className="space-y-2">
90
+ <div className="flex items-center justify-between gap-2">
91
+ <div className="flex min-w-0 items-center gap-2">
92
+ <GitBranchIcon size={14} className="shrink-0 text-blue-500" />
93
+ <span className="truncate text-sm font-medium">{payload.topic}</span>
94
+ </div>
95
+ <div className="inline-flex shrink-0 items-center gap-1">
96
+ <span className="rounded-full bg-[var(--agent-muted,#f3f4f6)] px-2 py-0.5 text-xs text-[var(--agent-muted-foreground,#6b7280)]">
97
+ 第 {round}/{totalRounds} 轮
98
+ </span>
99
+ {payload.final && (
100
+ <span className="rounded-full bg-green-500/15 px-2 py-0.5 text-xs text-green-700 dark:text-green-400">
101
+ 已收敛
102
+ </span>
103
+ )}
104
+ </div>
105
+ </div>
106
+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--agent-muted,#f3f4f6)]">
107
+ <div
108
+ className="h-full bg-blue-500 transition-all duration-200"
109
+ style={{ width: `${progressPercent}%` }}
110
+ />
111
+ </div>
112
+ </div>
113
+
114
+ <div className="space-y-2">
115
+ <div className="flex items-center gap-2 text-sm font-medium">
116
+ <ListChecksIcon size={14} className="text-blue-500" />
117
+ 子问题状态
118
+ </div>
119
+ <div className="space-y-2">
120
+ {subQuestions.map((q) => {
121
+ const kind = (q.kind ?? 'fact') as Kind;
122
+ const status = (q.status ?? 'pending') as Status;
123
+ return (
124
+ <div
125
+ key={q.id}
126
+ className="rounded-md border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] p-2.5"
127
+ >
128
+ <div className="mb-1.5 flex flex-wrap items-center gap-1.5">
129
+ <span className="rounded-full bg-[var(--agent-muted,#f3f4f6)] px-1.5 py-0.5 text-xs text-[var(--agent-muted-foreground,#6b7280)]">
130
+ {KIND_LABELS[kind] ?? kind}
131
+ </span>
132
+ <span className={`rounded-full px-1.5 py-0.5 text-xs ${STATUS_COLOR[status]}`}>
133
+ {STATUS_LABELS[status] ?? status}
134
+ </span>
135
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
136
+ 证据 {q.evidenceCount ?? 0}
137
+ </span>
138
+ </div>
139
+ <div className="text-sm">{q.question}</div>
140
+ {q.note && (
141
+ <div className="mt-1 whitespace-pre-wrap text-xs text-[var(--agent-muted-foreground,#6b7280)]">
142
+ {q.note}
143
+ </div>
144
+ )}
145
+ </div>
146
+ );
147
+ })}
148
+ </div>
149
+ </div>
150
+
151
+ {payload.decision && (
152
+ <div className="rounded-md border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-muted,#f3f4f6)]/40 px-2.5 py-2">
153
+ <div className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">下一步决策</div>
154
+ <div className="text-sm font-medium">
155
+ {decisionLabel ?? payload.decision.next}
156
+ </div>
157
+ {payload.decision.reason && (
158
+ <div className="mt-0.5 whitespace-pre-wrap text-xs text-[var(--agent-muted-foreground,#6b7280)]">
159
+ {payload.decision.reason}
160
+ </div>
161
+ )}
162
+ </div>
163
+ )}
164
+ </div>
165
+ </div>
166
+ );
167
+ };
168
+
169
+ export default ResearchPlanCard;