@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.
- package/dist/cards/ActionSegmentCard.d.ts +18 -0
- package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
- package/dist/cards/ActionSegmentCard.js +26 -0
- package/dist/cards/ActionSegmentCard.js.map +1 -0
- package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
- package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
- package/dist/cards/AnalysisDocumentCard.js +11 -0
- package/dist/cards/AnalysisDocumentCard.js.map +1 -0
- package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
- package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
- package/dist/cards/AskUserQuestionsCard.js +171 -0
- package/dist/cards/AskUserQuestionsCard.js.map +1 -0
- package/dist/cards/CoverageReportCard.d.ts +41 -0
- package/dist/cards/CoverageReportCard.d.ts.map +1 -0
- package/dist/cards/CoverageReportCard.js +55 -0
- package/dist/cards/CoverageReportCard.js.map +1 -0
- package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
- package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
- package/dist/cards/OrchestrationPlanCard.js +103 -0
- package/dist/cards/OrchestrationPlanCard.js.map +1 -0
- package/dist/cards/PlanSelectorCard.d.ts +40 -0
- package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
- package/dist/cards/PlanSelectorCard.js +106 -0
- package/dist/cards/PlanSelectorCard.js.map +1 -0
- package/dist/cards/PlanStepsCard.d.ts +18 -0
- package/dist/cards/PlanStepsCard.d.ts.map +1 -0
- package/dist/cards/PlanStepsCard.js +25 -0
- package/dist/cards/PlanStepsCard.js.map +1 -0
- package/dist/cards/QuizCard.d.ts +44 -0
- package/dist/cards/QuizCard.d.ts.map +1 -0
- package/dist/cards/QuizCard.js +113 -0
- package/dist/cards/QuizCard.js.map +1 -0
- package/dist/cards/ResearchPlanCard.d.ts +23 -0
- package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
- package/dist/cards/ResearchPlanCard.js +50 -0
- package/dist/cards/ResearchPlanCard.js.map +1 -0
- package/dist/cards/SearchSourcesCard.d.ts +29 -0
- package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
- package/dist/cards/SearchSourcesCard.js +83 -0
- package/dist/cards/SearchSourcesCard.js.map +1 -0
- package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
- package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
- package/dist/cards/SuggestedRepliesCard.js +14 -0
- package/dist/cards/SuggestedRepliesCard.js.map +1 -0
- package/dist/cards/SummaryMessageCard.d.ts +40 -0
- package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
- package/dist/cards/SummaryMessageCard.js +56 -0
- package/dist/cards/SummaryMessageCard.js.map +1 -0
- package/dist/cards/ThinkingProcessCard.d.ts +46 -0
- package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
- package/dist/cards/ThinkingProcessCard.js +79 -0
- package/dist/cards/ThinkingProcessCard.js.map +1 -0
- package/dist/cards/ToolExecutionCard.d.ts +21 -0
- package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
- package/dist/cards/ToolExecutionCard.js +65 -0
- package/dist/cards/ToolExecutionCard.js.map +1 -0
- package/dist/cards/icons.d.ts +38 -0
- package/dist/cards/icons.d.ts.map +1 -0
- package/dist/cards/icons.js +87 -0
- package/dist/cards/icons.js.map +1 -0
- package/dist/cards/index.d.ts +38 -0
- package/dist/cards/index.d.ts.map +1 -0
- package/dist/cards/index.js +24 -0
- package/dist/cards/index.js.map +1 -0
- package/dist/cards/types.d.ts +24 -0
- package/dist/cards/types.d.ts.map +1 -0
- package/dist/cards/types.js +7 -0
- package/dist/cards/types.js.map +1 -0
- package/dist/components/AgentSelector.d.ts +23 -0
- package/dist/components/AgentSelector.d.ts.map +1 -0
- package/dist/components/AgentSelector.js +63 -0
- package/dist/components/AgentSelector.js.map +1 -0
- package/dist/components/AgentSelector.stories.d.ts +12 -0
- package/dist/components/AgentSelector.stories.d.ts.map +1 -0
- package/dist/components/AgentSelector.stories.js +95 -0
- package/dist/components/AgentSelector.stories.js.map +1 -0
- package/dist/components/ChatPanel.d.ts +138 -21
- package/dist/components/ChatPanel.d.ts.map +1 -1
- package/dist/components/ChatPanel.js +181 -26
- package/dist/components/ChatPanel.js.map +1 -1
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/hooks/useChatStream.d.ts +15 -0
- package/dist/hooks/useChatStream.d.ts.map +1 -1
- package/dist/hooks/useChatStream.js +13 -1
- package/dist/hooks/useChatStream.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/state/ChatSessionProvider.d.ts +59 -0
- package/dist/state/ChatSessionProvider.d.ts.map +1 -0
- package/dist/state/ChatSessionProvider.js +71 -0
- package/dist/state/ChatSessionProvider.js.map +1 -0
- package/dist/state/MockChatStreamTransport.d.ts +54 -0
- package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
- package/dist/state/MockChatStreamTransport.js +86 -0
- package/dist/state/MockChatStreamTransport.js.map +1 -0
- package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
- package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
- package/dist/state/MockChatStreamTransport.stories.js +60 -0
- package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
- package/dist/state/bridgeLegacySSE.d.ts +42 -0
- package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
- package/dist/state/bridgeLegacySSE.js +102 -0
- package/dist/state/bridgeLegacySSE.js.map +1 -0
- package/dist/state/index.d.ts +32 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +24 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/parseSSE.d.ts +50 -0
- package/dist/state/parseSSE.d.ts.map +1 -0
- package/dist/state/parseSSE.js +112 -0
- package/dist/state/parseSSE.js.map +1 -0
- package/dist/state/useChatComposer.d.ts +51 -0
- package/dist/state/useChatComposer.d.ts.map +1 -0
- package/dist/state/useChatComposer.js +82 -0
- package/dist/state/useChatComposer.js.map +1 -0
- package/dist/state/useChatList.d.ts +73 -0
- package/dist/state/useChatList.d.ts.map +1 -0
- package/dist/state/useChatList.js +161 -0
- package/dist/state/useChatList.js.map +1 -0
- package/dist/state/useChatSession.d.ts +29 -0
- package/dist/state/useChatSession.d.ts.map +1 -0
- package/dist/state/useChatSession.js +37 -0
- package/dist/state/useChatSession.js.map +1 -0
- package/dist/state/useScrollLock.d.ts +35 -0
- package/dist/state/useScrollLock.d.ts.map +1 -0
- package/dist/state/useScrollLock.js +74 -0
- package/dist/state/useScrollLock.js.map +1 -0
- package/dist/state/useToolCallStream.d.ts +38 -0
- package/dist/state/useToolCallStream.d.ts.map +1 -0
- package/dist/state/useToolCallStream.js +74 -0
- package/dist/state/useToolCallStream.js.map +1 -0
- package/package.json +11 -3
- package/src/cards/ActionSegmentCard.tsx +60 -0
- package/src/cards/AnalysisDocumentCard.tsx +81 -0
- package/src/cards/AskUserQuestionsCard.tsx +354 -0
- package/src/cards/CoverageReportCard.tsx +215 -0
- package/src/cards/OrchestrationPlanCard.tsx +328 -0
- package/src/cards/PlanSelectorCard.tsx +312 -0
- package/src/cards/PlanStepsCard.tsx +52 -0
- package/src/cards/QuizCard.tsx +309 -0
- package/src/cards/ResearchPlanCard.tsx +169 -0
- package/src/cards/SearchSourcesCard.tsx +244 -0
- package/src/cards/SuggestedRepliesCard.tsx +48 -0
- package/src/cards/SummaryMessageCard.tsx +145 -0
- package/src/cards/ThinkingProcessCard.tsx +155 -0
- package/src/cards/ToolExecutionCard.tsx +152 -0
- package/src/cards/cards.test.tsx +492 -0
- package/src/cards/icons.tsx +249 -0
- package/src/cards/index.ts +53 -0
- package/src/cards/types.ts +27 -0
- package/src/components/AgentSelector.stories.tsx +113 -0
- package/src/components/AgentSelector.test.tsx +70 -0
- package/src/components/AgentSelector.tsx +147 -0
- package/src/components/ChatPanel.test.tsx +130 -0
- package/src/components/ChatPanel.tsx +612 -138
- package/src/components/index.ts +13 -1
- package/src/hooks/useChatStream.test.ts +78 -0
- package/src/hooks/useChatStream.ts +32 -1
- package/src/index.ts +43 -0
- package/src/state/ChatSessionProvider.test.tsx +85 -0
- package/src/state/ChatSessionProvider.tsx +98 -0
- package/src/state/MockChatStreamTransport.stories.tsx +95 -0
- package/src/state/MockChatStreamTransport.test.ts +163 -0
- package/src/state/MockChatStreamTransport.ts +123 -0
- package/src/state/bridgeLegacySSE.test.ts +134 -0
- package/src/state/bridgeLegacySSE.ts +134 -0
- package/src/state/index.ts +82 -0
- package/src/state/parseSSE.test.ts +123 -0
- package/src/state/parseSSE.ts +123 -0
- package/src/state/useChatComposer.test.ts +145 -0
- package/src/state/useChatComposer.ts +117 -0
- package/src/state/useChatList.test.ts +135 -0
- package/src/state/useChatList.ts +220 -0
- package/src/state/useChatSession.test.ts +61 -0
- package/src/state/useChatSession.ts +64 -0
- package/src/state/useScrollLock.test.ts +99 -0
- package/src/state/useScrollLock.ts +105 -0
- package/src/state/useToolCallStream.test.ts +86 -0
- 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;
|