@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.
- 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/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 +71 -0
- package/dist/state/useChatList.d.ts.map +1 -0
- package/dist/state/useChatList.js +158 -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 +10 -2
- 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/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 +217 -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,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ToolExecutionCard />`
|
|
3
|
+
*
|
|
4
|
+
* Unified renderer for a single tool / action invocation. Replaces both
|
|
5
|
+
* deeppath's ActionSegment inline strip and deeppath-agent's
|
|
6
|
+
* ExecutedActionsCard: one row per call with status / duration / expandable
|
|
7
|
+
* args + output JSON. The optional `renderArgs` / `renderOutput` slots let an
|
|
8
|
+
* app substitute a custom JSON viewer or rich payload renderer.
|
|
9
|
+
*/
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
import type { ToolExecutionPayload } from '@steerable/agent-protocol';
|
|
12
|
+
import {
|
|
13
|
+
CheckIcon,
|
|
14
|
+
LoaderIcon,
|
|
15
|
+
AlertIcon,
|
|
16
|
+
ZapIcon,
|
|
17
|
+
ChevronDownIcon,
|
|
18
|
+
ChevronRightIcon,
|
|
19
|
+
PlayIcon,
|
|
20
|
+
StopIcon,
|
|
21
|
+
} from './icons.js';
|
|
22
|
+
|
|
23
|
+
type Status = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
|
24
|
+
|
|
25
|
+
const STATUS_LABEL: Record<Status, string> = {
|
|
26
|
+
pending: '待执行',
|
|
27
|
+
running: '执行中',
|
|
28
|
+
succeeded: '已完成',
|
|
29
|
+
failed: '失败',
|
|
30
|
+
cancelled: '已取消',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const STATUS_TONE: Record<Status, string> = {
|
|
34
|
+
pending: 'text-[var(--agent-muted-foreground,#6b7280)]',
|
|
35
|
+
running: 'text-amber-600',
|
|
36
|
+
succeeded: 'text-emerald-600',
|
|
37
|
+
failed: 'text-rose-600',
|
|
38
|
+
cancelled: 'text-[var(--agent-muted-foreground,#9ca3af)]',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface ToolExecutionCardProps {
|
|
42
|
+
payload: ToolExecutionPayload;
|
|
43
|
+
className?: string;
|
|
44
|
+
defaultExpanded?: boolean;
|
|
45
|
+
renderArgs?: (args: unknown) => React.ReactNode;
|
|
46
|
+
renderOutput?: (output: unknown) => React.ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function StatusIcon({ status }: { status: Status }) {
|
|
50
|
+
const cls = STATUS_TONE[status];
|
|
51
|
+
switch (status) {
|
|
52
|
+
case 'running':
|
|
53
|
+
return <LoaderIcon size={12} className={cls} />;
|
|
54
|
+
case 'succeeded':
|
|
55
|
+
return <CheckIcon size={12} className={cls} />;
|
|
56
|
+
case 'failed':
|
|
57
|
+
return <AlertIcon size={12} className={cls} />;
|
|
58
|
+
case 'cancelled':
|
|
59
|
+
return <StopIcon size={12} className={cls} />;
|
|
60
|
+
default:
|
|
61
|
+
return <PlayIcon size={12} className={cls} />;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultRender(value: unknown): React.ReactNode {
|
|
66
|
+
if (value === undefined || value === null) return null;
|
|
67
|
+
let text: string;
|
|
68
|
+
try {
|
|
69
|
+
text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
70
|
+
} catch {
|
|
71
|
+
text = String(value);
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
<pre className="whitespace-pre-wrap break-words rounded bg-[var(--agent-muted,#f3f4f6)] px-2 py-1.5 text-[11px] text-[var(--agent-foreground,#111827)]">
|
|
75
|
+
{text}
|
|
76
|
+
</pre>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const ToolExecutionCard: React.FC<ToolExecutionCardProps> = ({
|
|
81
|
+
payload,
|
|
82
|
+
className,
|
|
83
|
+
defaultExpanded = false,
|
|
84
|
+
renderArgs,
|
|
85
|
+
renderOutput,
|
|
86
|
+
}) => {
|
|
87
|
+
const expandable = payload.expandable !== false;
|
|
88
|
+
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
|
89
|
+
const status = (payload.status ?? 'pending') as Status;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
className={[
|
|
94
|
+
'steerable-tool-execution rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-xs',
|
|
95
|
+
className,
|
|
96
|
+
].filter(Boolean).join(' ')}
|
|
97
|
+
>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => expandable && setExpanded((v) => !v)}
|
|
101
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
|
102
|
+
aria-expanded={expanded}
|
|
103
|
+
aria-disabled={!expandable}
|
|
104
|
+
>
|
|
105
|
+
{expandable &&
|
|
106
|
+
(expanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />)}
|
|
107
|
+
<ZapIcon size={12} className="text-[var(--agent-muted-foreground,#6b7280)]" />
|
|
108
|
+
<span className="font-medium text-[var(--agent-foreground,#111827)]">{payload.name}</span>
|
|
109
|
+
{payload.summary && (
|
|
110
|
+
<span className="truncate text-[var(--agent-muted-foreground,#6b7280)]">{payload.summary}</span>
|
|
111
|
+
)}
|
|
112
|
+
<span className="ml-auto inline-flex items-center gap-1">
|
|
113
|
+
<StatusIcon status={status} />
|
|
114
|
+
<span className={STATUS_TONE[status]}>{STATUS_LABEL[status]}</span>
|
|
115
|
+
{typeof payload.durationMs === 'number' && (
|
|
116
|
+
<span className="text-[var(--agent-muted-foreground,#9ca3af)]">· {payload.durationMs}ms</span>
|
|
117
|
+
)}
|
|
118
|
+
</span>
|
|
119
|
+
</button>
|
|
120
|
+
{expandable && expanded && (
|
|
121
|
+
<div className="space-y-2 border-t border-[var(--agent-border,#e5e7eb)] px-3 py-2">
|
|
122
|
+
{payload.args !== undefined && (
|
|
123
|
+
<section>
|
|
124
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]">
|
|
125
|
+
输入
|
|
126
|
+
</div>
|
|
127
|
+
{renderArgs ? renderArgs(payload.args) : defaultRender(payload.args)}
|
|
128
|
+
</section>
|
|
129
|
+
)}
|
|
130
|
+
{payload.output !== undefined && (
|
|
131
|
+
<section>
|
|
132
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]">
|
|
133
|
+
输出
|
|
134
|
+
</div>
|
|
135
|
+
{renderOutput ? renderOutput(payload.output) : defaultRender(payload.output)}
|
|
136
|
+
</section>
|
|
137
|
+
)}
|
|
138
|
+
{payload.error && (
|
|
139
|
+
<section>
|
|
140
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-rose-600">错误</div>
|
|
141
|
+
<pre className="whitespace-pre-wrap break-words rounded bg-rose-50/60 px-2 py-1.5 text-[11px] text-rose-700">
|
|
142
|
+
{payload.error}
|
|
143
|
+
</pre>
|
|
144
|
+
</section>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export default ToolExecutionCard;
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the 14 cards. We focus on render-without-crash + the
|
|
3
|
+
* payload-driven branching that's most likely to regress (read-only vs
|
|
4
|
+
* interactive, expand toggle, callbacks). Full visual coverage lives in
|
|
5
|
+
* Storybook VRT.
|
|
6
|
+
*/
|
|
7
|
+
import * as React from 'react';
|
|
8
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
OrchestrationPlanCard,
|
|
13
|
+
QuizCard,
|
|
14
|
+
CoverageReportCard,
|
|
15
|
+
AnalysisDocumentCard,
|
|
16
|
+
ResearchPlanCard,
|
|
17
|
+
SuggestedRepliesCard,
|
|
18
|
+
AskUserQuestionsCard,
|
|
19
|
+
ThinkingProcessCard,
|
|
20
|
+
PlanStepsCard,
|
|
21
|
+
PlanSelectorCard,
|
|
22
|
+
SearchSourcesCard,
|
|
23
|
+
SummaryMessageCard,
|
|
24
|
+
ActionSegmentCard,
|
|
25
|
+
ToolExecutionCard,
|
|
26
|
+
} from './index.js';
|
|
27
|
+
|
|
28
|
+
afterEach(() => cleanup());
|
|
29
|
+
|
|
30
|
+
describe('SuggestedRepliesCard', () => {
|
|
31
|
+
it('calls onSelect when a chip is clicked', () => {
|
|
32
|
+
const onSelect = vi.fn();
|
|
33
|
+
render(
|
|
34
|
+
<SuggestedRepliesCard
|
|
35
|
+
payload={{ suggestions: ['继续', '换个角度'] }}
|
|
36
|
+
onSelect={onSelect}
|
|
37
|
+
/>,
|
|
38
|
+
);
|
|
39
|
+
fireEvent.click(screen.getByText('换个角度'));
|
|
40
|
+
expect(onSelect).toHaveBeenCalledWith('换个角度');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('renders nothing when suggestions is empty', () => {
|
|
44
|
+
const { container } = render(
|
|
45
|
+
<SuggestedRepliesCard payload={{ suggestions: [] }} onSelect={() => {}} />,
|
|
46
|
+
);
|
|
47
|
+
expect(container.firstChild).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('PlanStepsCard', () => {
|
|
52
|
+
it('renders steps separated by /', () => {
|
|
53
|
+
render(<PlanStepsCard payload={{ steps: ['搜索', '汇总', '回复'] }} />);
|
|
54
|
+
expect(screen.getByText('搜索')).toBeTruthy();
|
|
55
|
+
expect(screen.getByText('汇总')).toBeTruthy();
|
|
56
|
+
expect(screen.getByText('回复')).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('hides itself with no steps and not streaming', () => {
|
|
60
|
+
const { container } = render(<PlanStepsCard payload={{ steps: [] }} />);
|
|
61
|
+
expect(container.firstChild).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('OrchestrationPlanCard', () => {
|
|
66
|
+
const payload = {
|
|
67
|
+
rationale: '需要并行收集 3 个领域的资料',
|
|
68
|
+
mode: 'parallel',
|
|
69
|
+
tasks: [
|
|
70
|
+
{ id: 't1', agentId: 'researcher.a', prompt: '搜索 A' },
|
|
71
|
+
{ id: 't2', agentId: 'researcher.b', prompt: '搜索 B' },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
it('shows task rows and counts', () => {
|
|
76
|
+
render(
|
|
77
|
+
<OrchestrationPlanCard
|
|
78
|
+
payload={payload}
|
|
79
|
+
taskStatuses={{ t1: 'ok', t2: 'running' }}
|
|
80
|
+
agentNameFor={(id) => id.replace('researcher.', '')}
|
|
81
|
+
/>,
|
|
82
|
+
);
|
|
83
|
+
expect(screen.getByText('a')).toBeTruthy();
|
|
84
|
+
expect(screen.getByText('b')).toBeTruthy();
|
|
85
|
+
expect(screen.getByText('1/2')).toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('collapses on header click', () => {
|
|
89
|
+
render(<OrchestrationPlanCard payload={payload} />);
|
|
90
|
+
fireEvent.click(screen.getByText('编排计划'));
|
|
91
|
+
expect(screen.queryByText('搜索 A')).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('renders loading shell when loading=true', () => {
|
|
95
|
+
render(
|
|
96
|
+
<OrchestrationPlanCard
|
|
97
|
+
payload={{ tasks: [] }}
|
|
98
|
+
loading
|
|
99
|
+
loadingHint="正在规划"
|
|
100
|
+
headerLabel="协调员"
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByText('正在规划')).toBeTruthy();
|
|
104
|
+
expect(screen.getByText('协调员')).toBeTruthy();
|
|
105
|
+
expect(screen.queryByText('编排计划')).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders failed shell with failure text', () => {
|
|
109
|
+
render(
|
|
110
|
+
<OrchestrationPlanCard
|
|
111
|
+
payload={{ tasks: [] }}
|
|
112
|
+
failed
|
|
113
|
+
failure="超时"
|
|
114
|
+
headerLabel="协调员"
|
|
115
|
+
/>,
|
|
116
|
+
);
|
|
117
|
+
expect(screen.getByText('规划失败')).toBeTruthy();
|
|
118
|
+
expect(screen.getByText('超时')).toBeTruthy();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('uses renderTaskRow override and hides mode + counts', () => {
|
|
122
|
+
render(
|
|
123
|
+
<OrchestrationPlanCard
|
|
124
|
+
payload={payload}
|
|
125
|
+
taskStatuses={{ t1: 'ok', t2: 'running' }}
|
|
126
|
+
hideMode
|
|
127
|
+
hideHeaderCounts
|
|
128
|
+
headerLabel="协调员安排"
|
|
129
|
+
headerSecondary="2 个子任务"
|
|
130
|
+
renderTaskRow={(task, status) => (
|
|
131
|
+
<li key={task.id} data-task={task.id} data-status={status}>
|
|
132
|
+
{`row:${task.agentId}:${status}`}
|
|
133
|
+
</li>
|
|
134
|
+
)}
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByText('row:researcher.a:ok')).toBeTruthy();
|
|
138
|
+
expect(screen.getByText('row:researcher.b:running')).toBeTruthy();
|
|
139
|
+
expect(screen.queryByText('parallel')).toBeNull();
|
|
140
|
+
expect(screen.getByText('2 个子任务')).toBeTruthy();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('AskUserQuestionsCard', () => {
|
|
145
|
+
it('submits selected answers', () => {
|
|
146
|
+
const onSubmit = vi.fn();
|
|
147
|
+
render(
|
|
148
|
+
<AskUserQuestionsCard
|
|
149
|
+
payload={{
|
|
150
|
+
intro: '请回答',
|
|
151
|
+
questions: [
|
|
152
|
+
{ id: 'q1', text: '你的目标?', type: 'select', options: ['学习', '工作'] },
|
|
153
|
+
],
|
|
154
|
+
}}
|
|
155
|
+
onSubmit={onSubmit}
|
|
156
|
+
/>,
|
|
157
|
+
);
|
|
158
|
+
fireEvent.click(screen.getByText('学习'));
|
|
159
|
+
fireEvent.click(screen.getByText('提交'));
|
|
160
|
+
expect(onSubmit).toHaveBeenCalledWith({ q1: '学习' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('switches to read-only when payload.answers is filled', () => {
|
|
164
|
+
render(
|
|
165
|
+
<AskUserQuestionsCard
|
|
166
|
+
payload={{
|
|
167
|
+
intro: '请回答',
|
|
168
|
+
questions: [
|
|
169
|
+
{ id: 'q1', text: '你的目标?', type: 'select', options: ['学习', '工作'] },
|
|
170
|
+
],
|
|
171
|
+
answers: { q1: '学习' },
|
|
172
|
+
}}
|
|
173
|
+
onSubmit={vi.fn()}
|
|
174
|
+
/>,
|
|
175
|
+
);
|
|
176
|
+
expect(screen.queryByText('提交')).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('exposes auto-continue button when onAutoContinue provided', () => {
|
|
180
|
+
const onAutoContinue = vi.fn();
|
|
181
|
+
render(
|
|
182
|
+
<AskUserQuestionsCard
|
|
183
|
+
payload={{
|
|
184
|
+
intro: '请回答',
|
|
185
|
+
questions: [
|
|
186
|
+
{ id: 'q1', text: '你的目标?', type: 'select', options: ['学习', '工作'] },
|
|
187
|
+
],
|
|
188
|
+
}}
|
|
189
|
+
onSubmit={vi.fn()}
|
|
190
|
+
onAutoContinue={onAutoContinue}
|
|
191
|
+
autoContinueLabel="交给我决定"
|
|
192
|
+
/>,
|
|
193
|
+
);
|
|
194
|
+
fireEvent.click(screen.getByText('交给我决定'));
|
|
195
|
+
expect(onAutoContinue).toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('sends free-text answer when allowCustomText + custom pill used', () => {
|
|
199
|
+
const onSubmit = vi.fn();
|
|
200
|
+
render(
|
|
201
|
+
<AskUserQuestionsCard
|
|
202
|
+
payload={{
|
|
203
|
+
intro: '请回答',
|
|
204
|
+
questions: [
|
|
205
|
+
{ id: 'q1', text: '其他想法?', type: 'select', options: ['学习', '工作'] },
|
|
206
|
+
],
|
|
207
|
+
}}
|
|
208
|
+
allowCustomText
|
|
209
|
+
customLabel="自定义"
|
|
210
|
+
onSubmit={onSubmit}
|
|
211
|
+
/>,
|
|
212
|
+
);
|
|
213
|
+
fireEvent.click(screen.getByText('自定义'));
|
|
214
|
+
const input = screen.getByPlaceholderText('输入你的回答...');
|
|
215
|
+
fireEvent.change(input, { target: { value: '休息' } });
|
|
216
|
+
fireEvent.click(screen.getByText('提交'));
|
|
217
|
+
expect(onSubmit).toHaveBeenCalledWith({ q1: '休息' });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('QuizCard', () => {
|
|
222
|
+
it('submits selected choice', () => {
|
|
223
|
+
const onSubmit = vi.fn();
|
|
224
|
+
render(
|
|
225
|
+
<QuizCard
|
|
226
|
+
payload={{
|
|
227
|
+
quizId: 'q',
|
|
228
|
+
title: '测验',
|
|
229
|
+
description: null,
|
|
230
|
+
submitActionLabel: '提交',
|
|
231
|
+
questions: [
|
|
232
|
+
{ id: 'q1', type: 'choice', stem: '一加一', options: ['1', '2'], allowMultiple: false, placeholder: null, points: null, knowledgePointId: null, difficulty: null },
|
|
233
|
+
],
|
|
234
|
+
}}
|
|
235
|
+
onSubmit={onSubmit}
|
|
236
|
+
/>,
|
|
237
|
+
);
|
|
238
|
+
fireEvent.click(screen.getByText('2'));
|
|
239
|
+
fireEvent.click(screen.getByText('提交'));
|
|
240
|
+
expect(onSubmit).toHaveBeenCalledWith({ q1: '2' });
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('CoverageReportCard', () => {
|
|
245
|
+
it('renders overall percentages', () => {
|
|
246
|
+
render(
|
|
247
|
+
<CoverageReportCard
|
|
248
|
+
payload={{
|
|
249
|
+
reportId: 'r',
|
|
250
|
+
title: '覆盖度报告',
|
|
251
|
+
overallCoverage: 0.75,
|
|
252
|
+
overallMastery: 0.5,
|
|
253
|
+
summary: null,
|
|
254
|
+
sections: [],
|
|
255
|
+
weakPoints: [],
|
|
256
|
+
actions: { allowRemediateQuiz: false, remediateActionLabel: '' },
|
|
257
|
+
}}
|
|
258
|
+
/>,
|
|
259
|
+
);
|
|
260
|
+
expect(screen.getByText('75%')).toBeTruthy();
|
|
261
|
+
expect(screen.getByText('50%')).toBeTruthy();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('AnalysisDocumentCard', () => {
|
|
266
|
+
it('uses renderMarkdown slot', () => {
|
|
267
|
+
render(
|
|
268
|
+
<AnalysisDocumentCard
|
|
269
|
+
payload={{ title: 't', body: '# hi', createdAt: null, modelId: null }}
|
|
270
|
+
renderMarkdown={(body) => <span data-testid="md">{body}</span>}
|
|
271
|
+
/>,
|
|
272
|
+
);
|
|
273
|
+
expect(screen.getByTestId('md').textContent).toBe('# hi');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('ResearchPlanCard', () => {
|
|
278
|
+
it('shows topic and decision', () => {
|
|
279
|
+
render(
|
|
280
|
+
<ResearchPlanCard
|
|
281
|
+
payload={{
|
|
282
|
+
topic: '研究 X',
|
|
283
|
+
round: 2,
|
|
284
|
+
final: false,
|
|
285
|
+
subQuestions: [
|
|
286
|
+
{ id: 's1', question: '什么是 X', kind: 'fact', status: 'evidenced_strong', evidenceCount: 3, note: null },
|
|
287
|
+
],
|
|
288
|
+
decision: { next: 'expand', reason: '需要更多证据' },
|
|
289
|
+
}}
|
|
290
|
+
/>,
|
|
291
|
+
);
|
|
292
|
+
expect(screen.getByText('研究 X')).toBeTruthy();
|
|
293
|
+
expect(screen.getByText(/横向扩展/)).toBeTruthy();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('ThinkingProcessCard', () => {
|
|
298
|
+
it('toggles expanded', () => {
|
|
299
|
+
render(<ThinkingProcessCard payload={{ body: 'reasoning', defaultExpanded: false }} />);
|
|
300
|
+
expect(screen.queryByText('reasoning')).toBeNull();
|
|
301
|
+
fireEvent.click(screen.getByText('思考过程'));
|
|
302
|
+
expect(screen.getByText('reasoning')).toBeTruthy();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('PlanSelectorCard', () => {
|
|
307
|
+
it('emits planId when picked', () => {
|
|
308
|
+
const onSelect = vi.fn();
|
|
309
|
+
render(
|
|
310
|
+
<PlanSelectorCard
|
|
311
|
+
payload={{
|
|
312
|
+
comparison: '',
|
|
313
|
+
goalAttribution: { type: 'new', newGoalTitle: '新目标' },
|
|
314
|
+
plans: [
|
|
315
|
+
{
|
|
316
|
+
id: 'p1',
|
|
317
|
+
name: '稳健方案',
|
|
318
|
+
summary: '走过验证路径',
|
|
319
|
+
approach: 'a',
|
|
320
|
+
bestFor: '稳健',
|
|
321
|
+
metrics: { duration: '1周', effortLevel: 'low', riskLevel: 'low' },
|
|
322
|
+
pros: ['稳定'],
|
|
323
|
+
cons: ['慢'],
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
}}
|
|
327
|
+
onSelect={onSelect}
|
|
328
|
+
/>,
|
|
329
|
+
);
|
|
330
|
+
fireEvent.click(screen.getByText('选择此方案'));
|
|
331
|
+
expect(onSelect).toHaveBeenCalledWith('p1');
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('SearchSourcesCard', () => {
|
|
336
|
+
it('shows source count and expand toggle', () => {
|
|
337
|
+
const { container } = render(
|
|
338
|
+
<SearchSourcesCard
|
|
339
|
+
payload={{
|
|
340
|
+
sources: [
|
|
341
|
+
{ url: 'https://example.com', title: 'Example', snippet: 'sn', favicon: null, publishedAt: null },
|
|
342
|
+
],
|
|
343
|
+
}}
|
|
344
|
+
/>,
|
|
345
|
+
);
|
|
346
|
+
expect(screen.getByText(/1 个来源/)).toBeTruthy();
|
|
347
|
+
const trigger = container.querySelector('button');
|
|
348
|
+
if (!trigger) throw new Error('no toggle');
|
|
349
|
+
fireEvent.click(trigger);
|
|
350
|
+
expect(screen.getByText('Example')).toBeTruthy();
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe('SummaryMessageCard', () => {
|
|
355
|
+
it('reveals body when expanded', () => {
|
|
356
|
+
render(
|
|
357
|
+
<SummaryMessageCard
|
|
358
|
+
payload={{ body: 'summary body', summarizedCount: 3, status: 'complete', type: null }}
|
|
359
|
+
/>,
|
|
360
|
+
);
|
|
361
|
+
fireEvent.click(screen.getByText(/历史摘要/));
|
|
362
|
+
expect(screen.getByText('summary body')).toBeTruthy();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('honours custom renderLabel slot', () => {
|
|
366
|
+
render(
|
|
367
|
+
<SummaryMessageCard
|
|
368
|
+
payload={{ body: 'b', summarizedCount: 7, status: 'complete', type: null }}
|
|
369
|
+
renderLabel={({ count }) => `已总结 ${count} 条`}
|
|
370
|
+
/>,
|
|
371
|
+
);
|
|
372
|
+
expect(screen.getByText('已总结 7 条')).toBeTruthy();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('renders inline appearance without card chrome', () => {
|
|
376
|
+
const { container } = render(
|
|
377
|
+
<SummaryMessageCard
|
|
378
|
+
payload={{ body: 'b', summarizedCount: 1, status: 'complete', type: null }}
|
|
379
|
+
appearance="inline"
|
|
380
|
+
/>,
|
|
381
|
+
);
|
|
382
|
+
const root = container.firstElementChild as HTMLElement;
|
|
383
|
+
expect(root.className.includes('rounded-lg')).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('SearchSourcesCard appearance', () => {
|
|
388
|
+
it('renders inline appearance with deeppath label', () => {
|
|
389
|
+
render(
|
|
390
|
+
<SearchSourcesCard
|
|
391
|
+
payload={{
|
|
392
|
+
sources: [
|
|
393
|
+
{ url: 'https://example.com', title: 'Example', snippet: null, favicon: null, publishedAt: null },
|
|
394
|
+
],
|
|
395
|
+
}}
|
|
396
|
+
appearance="inline"
|
|
397
|
+
/>,
|
|
398
|
+
);
|
|
399
|
+
expect(screen.getByText(/搜索了 1 个网站/)).toBeTruthy();
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe('ResearchPlanCard progress', () => {
|
|
404
|
+
it('uses totalRounds to compute progress bar width', () => {
|
|
405
|
+
const { container } = render(
|
|
406
|
+
<ResearchPlanCard
|
|
407
|
+
payload={{
|
|
408
|
+
topic: 'topic',
|
|
409
|
+
round: 2,
|
|
410
|
+
final: false,
|
|
411
|
+
subQuestions: [
|
|
412
|
+
{ id: 's', question: 'q', kind: 'fact', status: 'pending', evidenceCount: 0, note: null },
|
|
413
|
+
],
|
|
414
|
+
decision: { next: 'continue', reason: null },
|
|
415
|
+
}}
|
|
416
|
+
totalRounds={4}
|
|
417
|
+
/>,
|
|
418
|
+
);
|
|
419
|
+
const bar = container.querySelector('[style*="width"]') as HTMLElement | null;
|
|
420
|
+
expect(bar?.style.width).toBe('50%');
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('CoverageReportCard selection mode', () => {
|
|
425
|
+
it('emits selected ids when allowWeakPointSelection is on', () => {
|
|
426
|
+
const onRemediate = vi.fn();
|
|
427
|
+
render(
|
|
428
|
+
<CoverageReportCard
|
|
429
|
+
payload={{
|
|
430
|
+
reportId: 'r',
|
|
431
|
+
title: 't',
|
|
432
|
+
overallCoverage: 0,
|
|
433
|
+
overallMastery: 0,
|
|
434
|
+
summary: null,
|
|
435
|
+
sections: [],
|
|
436
|
+
weakPoints: [
|
|
437
|
+
{ id: 'w1', name: 'wp1', sectionName: null, accuracy: 0.3, recommendation: 'do' },
|
|
438
|
+
{ id: 'w2', name: 'wp2', sectionName: null, accuracy: 0.5, recommendation: 'do' },
|
|
439
|
+
],
|
|
440
|
+
actions: { allowRemediateQuiz: true, remediateActionLabel: '出题' },
|
|
441
|
+
}}
|
|
442
|
+
allowWeakPointSelection
|
|
443
|
+
onRemediate={onRemediate}
|
|
444
|
+
/>,
|
|
445
|
+
);
|
|
446
|
+
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
|
447
|
+
fireEvent.click(checkboxes[0]!);
|
|
448
|
+
fireEvent.click(screen.getByText('出题'));
|
|
449
|
+
expect(onRemediate).toHaveBeenCalledWith(['w1']);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe('ToolExecutionCard', () => {
|
|
454
|
+
it('expands to show args/output and renders error', () => {
|
|
455
|
+
render(
|
|
456
|
+
<ToolExecutionCard
|
|
457
|
+
payload={{
|
|
458
|
+
id: 't',
|
|
459
|
+
name: 'search.web',
|
|
460
|
+
status: 'failed',
|
|
461
|
+
summary: '搜索失败',
|
|
462
|
+
args: { q: 'foo' },
|
|
463
|
+
output: null,
|
|
464
|
+
error: 'timeout',
|
|
465
|
+
durationMs: 1200,
|
|
466
|
+
icon: null,
|
|
467
|
+
expandable: true,
|
|
468
|
+
}}
|
|
469
|
+
/>,
|
|
470
|
+
);
|
|
471
|
+
fireEvent.click(screen.getByText('search.web'));
|
|
472
|
+
expect(screen.getByText(/"q": "foo"/)).toBeTruthy();
|
|
473
|
+
expect(screen.getByText('timeout')).toBeTruthy();
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
describe('ActionSegmentCard', () => {
|
|
478
|
+
it('renders each segment via ToolExecutionCard', () => {
|
|
479
|
+
render(
|
|
480
|
+
<ActionSegmentCard
|
|
481
|
+
payload={{
|
|
482
|
+
segments: [
|
|
483
|
+
{ id: 's1', kind: 'fetch', status: 'succeeded', label: '抓取', args: null, output: null, error: null, startedAt: null, finishedAt: null },
|
|
484
|
+
{ id: 's2', kind: 'parse', status: 'running', label: '解析', args: null, output: null, error: null, startedAt: null, finishedAt: null },
|
|
485
|
+
],
|
|
486
|
+
}}
|
|
487
|
+
/>,
|
|
488
|
+
);
|
|
489
|
+
expect(screen.getByText('fetch')).toBeTruthy();
|
|
490
|
+
expect(screen.getByText('parse')).toBeTruthy();
|
|
491
|
+
});
|
|
492
|
+
});
|