@adecore/agents-react 0.20.0 → 0.21.0
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/chat/chat-client.d.ts +1 -0
- package/dist/chat/logic/generated-image.d.ts +27 -0
- package/dist/chat/logic/generated-image.js +32 -0
- package/dist/chat/logic/timeline-target.d.ts +19 -1
- package/dist/chat/logic/timeline-target.js +49 -3
- package/dist/chat/logic/timeline.js +25 -17
- package/dist/chat/subagent-conversation.d.ts +2 -1
- package/dist/chat/subagent-conversation.js +7 -1
- package/dist/chat/timeline-flash.d.ts +3 -1
- package/dist/chat/timeline-flash.js +2 -2
- package/dist/chat/ui/CodeBlock.d.ts +3 -1
- package/dist/chat/ui/CodeBlock.js +38 -7
- package/dist/chat/ui/Composer.js +13 -3
- package/dist/chat/ui/ImageView.d.ts +8 -2
- package/dist/chat/ui/ImageView.js +12 -6
- package/dist/chat/ui/Markdown.d.ts +7 -1
- package/dist/chat/ui/Markdown.js +80 -23
- package/dist/chat/ui/MessageActions.js +6 -7
- package/dist/chat/ui/SubagentTimeline.js +7 -3
- package/dist/chat/ui/Timeline.js +68 -8
- package/dist/chat/ui/TimelineMenu.js +7 -3
- package/dist/chat/ui/file-links.d.ts +2 -1
- package/dist/chat/ui/file-links.js +8 -3
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +15 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +50 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +61 -0
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
- package/dist/chat/ui/intelligent-ui/UiReply.d.ts +13 -0
- package/dist/chat/ui/intelligent-ui/UiReply.js +325 -0
- package/dist/chat/ui/intelligent-ui/block-local.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/block-local.js +13 -0
- package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
- package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
- package/dist/chat/ui/intelligent-ui/node-text.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/node-text.js +52 -0
- package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/registry.js +50 -0
- package/dist/chat/ui/intelligent-ui/render-context.d.ts +50 -0
- package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
- package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
- package/dist/chat/ui/intelligent-ui/renderers/chart.js +125 -0
- package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
- package/dist/chat/ui/intelligent-ui/renderers/choices.js +41 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.js +66 -0
- package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/renderers/data.js +87 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.js +60 -0
- package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/renderers/links.js +86 -0
- package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
- package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
- package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
- package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
- package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
- package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
- package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
- package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/tones.js +31 -0
- package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
- package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
- package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/use-ui-links.js +107 -0
- package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +20 -0
- package/dist/chat/ui/intelligent-ui/use-ui-queries.js +259 -0
- package/dist/chat/ui/reply-context.d.ts +8 -0
- package/dist/chat/ui/reply-context.js +1 -0
- package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
- package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
- package/dist/chat/ui/rows/MessageRows.js +27 -7
- package/dist/chat/ui/rows/Rows.js +4 -2
- package/dist/chat/ui/rows/SubagentRow.js +5 -0
- package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
- package/dist/chat/ui/rows/WorkRows.js +4 -3
- package/dist/host.d.ts +25 -3
- package/dist/host.js +1 -0
- package/dist/locales/en/agent-chat.json +89 -1
- package/dist/locales/nl/agent-chat.json +89 -1
- package/dist/state/chats.js +9 -0
- package/dist/theme.css +38 -2
- package/package.json +133 -7
- package/src/chat/chat-client.ts +1 -0
- package/src/chat/logic/generated-image.ts +48 -0
- package/src/chat/logic/timeline-target.ts +69 -4
- package/src/chat/logic/timeline.ts +28 -17
- package/src/chat/subagent-conversation.ts +13 -2
- package/src/chat/timeline-flash.ts +5 -3
- package/src/chat/ui/CodeBlock.tsx +70 -21
- package/src/chat/ui/Composer.tsx +22 -5
- package/src/chat/ui/ImageView.tsx +51 -8
- package/src/chat/ui/Markdown.tsx +123 -26
- package/src/chat/ui/MessageActions.tsx +15 -8
- package/src/chat/ui/SubagentTimeline.tsx +26 -1
- package/src/chat/ui/Timeline.tsx +98 -22
- package/src/chat/ui/TimelineMenu.tsx +6 -3
- package/src/chat/ui/file-links.ts +9 -3
- package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +139 -0
- package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +89 -0
- package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
- package/src/chat/ui/intelligent-ui/UiReply.tsx +531 -0
- package/src/chat/ui/intelligent-ui/block-local.ts +16 -0
- package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
- package/src/chat/ui/intelligent-ui/node-text.ts +59 -0
- package/src/chat/ui/intelligent-ui/registry.ts +52 -0
- package/src/chat/ui/intelligent-ui/render-context.ts +78 -0
- package/src/chat/ui/intelligent-ui/renderers/chart.tsx +321 -0
- package/src/chat/ui/intelligent-ui/renderers/choices.tsx +98 -0
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +145 -0
- package/src/chat/ui/intelligent-ui/renderers/data.tsx +187 -0
- package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +121 -0
- package/src/chat/ui/intelligent-ui/renderers/links.tsx +157 -0
- package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
- package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
- package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
- package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
- package/src/chat/ui/intelligent-ui/tones.ts +37 -0
- package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
- package/src/chat/ui/intelligent-ui/use-ui-links.ts +123 -0
- package/src/chat/ui/intelligent-ui/use-ui-queries.ts +282 -0
- package/src/chat/ui/reply-context.ts +7 -0
- package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
- package/src/chat/ui/rows/MessageRows.tsx +69 -7
- package/src/chat/ui/rows/Rows.tsx +4 -2
- package/src/chat/ui/rows/SubagentRow.tsx +5 -0
- package/src/chat/ui/rows/WorkRows.tsx +20 -5
- package/src/host.ts +46 -3
- package/src/locales/en/agent-chat.json +89 -1
- package/src/locales/nl/agent-chat.json +89 -1
- package/src/state/chats.ts +9 -0
- package/src/theme.css +38 -2
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createContext, useContext, useId } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { ChevronRight } from 'lucide-react';
|
|
4
|
+
import type { UiProps } from '@adecore/intelligent-ui';
|
|
5
|
+
import { Icon, Tabs } from '@adecore/ui';
|
|
6
|
+
import { useBlockLocal } from '../block-local';
|
|
7
|
+
import { uiChildrenOf } from '../node-text';
|
|
8
|
+
import type { UiRendererProps } from '../render-context';
|
|
9
|
+
|
|
10
|
+
interface SectionsFold {
|
|
11
|
+
/* Whether the person opened or closed a section of this set, after which nothing opens by itself. */
|
|
12
|
+
touched: boolean;
|
|
13
|
+
touch(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SectionsContext = createContext<SectionsFold>({ touched: false, touch: () => undefined });
|
|
17
|
+
|
|
18
|
+
/* A tab that arrives while the block streams is not picked: the one that was open stays open. */
|
|
19
|
+
export function TabsRenderer({ node, children, context }: UiRendererProps<UiProps<'Tabs'>>) {
|
|
20
|
+
const tabs = uiChildrenOf(node, 'Tab');
|
|
21
|
+
const [picked, setPicked] = useBlockLocal<string | null>(context, node.id, () => null);
|
|
22
|
+
const value = picked !== null && tabs.some((tab) => tab.id === picked) ? picked : (tabs[0]?.id ?? null);
|
|
23
|
+
return (
|
|
24
|
+
<Tabs.Root value={value} onValueChange={(next) => setPicked(String(next))} className="flex flex-col gap-3">
|
|
25
|
+
<Tabs.List className="px-2">
|
|
26
|
+
{tabs.map((tab) => (
|
|
27
|
+
<Tabs.Tab key={tab.id} value={tab.id} data-ui-reveal={tab.id}>
|
|
28
|
+
{tab.props.title}
|
|
29
|
+
</Tabs.Tab>
|
|
30
|
+
))}
|
|
31
|
+
</Tabs.List>
|
|
32
|
+
{children}
|
|
33
|
+
</Tabs.Root>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function TabRenderer({ node, children }: UiRendererProps<UiProps<'Tab'>>) {
|
|
38
|
+
return (
|
|
39
|
+
<Tabs.Panel value={node.id} className="chat-ui-nodes flex flex-col gap-3">
|
|
40
|
+
{children}
|
|
41
|
+
</Tabs.Panel>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SectionsRenderer({ node, children, context }: UiRendererProps<UiProps<'Sections'>>) {
|
|
46
|
+
const [touched, setTouched] = useBlockLocal(context, node.id, () => false);
|
|
47
|
+
return (
|
|
48
|
+
<SectionsContext value={{ touched, touch: () => setTouched(true) }}>
|
|
49
|
+
<div className="chat-ui-nodes flex flex-col divide-y divide-border-soft">{children}</div>
|
|
50
|
+
</SectionsContext>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Arrives open until the person folds a section of the set themselves; the content starts under the title. */
|
|
55
|
+
export function SectionRenderer({ node, children, context }: UiRendererProps<UiProps<'Section'>>) {
|
|
56
|
+
const fold = useContext(SectionsContext);
|
|
57
|
+
const [open, setOpen] = useBlockLocal(context, node.id, () => !fold.touched);
|
|
58
|
+
const contentId = useId();
|
|
59
|
+
return (
|
|
60
|
+
<section>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
data-ui-reveal={node.id}
|
|
64
|
+
aria-expanded={open}
|
|
65
|
+
aria-controls={contentId}
|
|
66
|
+
className="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm font-medium text-text hover:bg-surface-hover"
|
|
67
|
+
onClick={() => {
|
|
68
|
+
fold.touch();
|
|
69
|
+
setOpen(!open);
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<Icon
|
|
73
|
+
icon={ChevronRight}
|
|
74
|
+
size={14}
|
|
75
|
+
className={clsx('shrink-0 text-text-faint transition-transform duration-150 motion-reduce:transition-none', open && 'rotate-90')}
|
|
76
|
+
/>
|
|
77
|
+
<span className="min-w-0 truncate">{node.props.title}</span>
|
|
78
|
+
</button>
|
|
79
|
+
<div id={contentId} hidden={!open} className="chat-ui-nodes flex flex-col gap-3 pt-1 pr-2 pb-2 pl-7.5">
|
|
80
|
+
{children}
|
|
81
|
+
</div>
|
|
82
|
+
</section>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { Circle, CircleCheck, CircleDot, CircleMinus, CircleX, type LucideIcon } from 'lucide-react';
|
|
5
|
+
import type { UiProps } from '@adecore/intelligent-ui';
|
|
6
|
+
import { Icon, Meter, Pill, Spinner } from '@adecore/ui';
|
|
7
|
+
import { formatNumber, formatPercent } from '@adecore/ui/format';
|
|
8
|
+
import { UiHeadContext, type UiRenderContext, type UiRendererProps } from '../render-context';
|
|
9
|
+
import { uiNodeLabel } from '../node-text';
|
|
10
|
+
import { UI_TONE_ICONS, UI_TONE_PILL, UI_TONE_SURFACE, UI_TONE_TEXT } from '../tones';
|
|
11
|
+
|
|
12
|
+
type StepState = UiProps<'Step'>['state'];
|
|
13
|
+
|
|
14
|
+
const STEP_ICONS: Readonly<Record<Exclude<StepState, 'running'>, LucideIcon>> = {
|
|
15
|
+
done: CircleCheck,
|
|
16
|
+
pending: Circle,
|
|
17
|
+
failed: CircleX,
|
|
18
|
+
skipped: CircleMinus
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const STEP_TONES: Readonly<Record<StepState, string>> = {
|
|
22
|
+
done: 'text-status-idle',
|
|
23
|
+
running: 'text-accent',
|
|
24
|
+
pending: 'text-text-faint',
|
|
25
|
+
failed: 'text-status-error',
|
|
26
|
+
skipped: 'text-text-faint'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/* Whether the block still changes: while it streams, or while it reads live data. Only then does anything spin. */
|
|
30
|
+
function isLive(context: UiRenderContext): boolean {
|
|
31
|
+
return context.phase === 'streaming' || (context.live !== undefined && context.live !== null && context.live.state !== 'refused');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* The head of the block when it is the first node, a subheading without an icon further down. */
|
|
35
|
+
export function SummaryRenderer({ node, children }: UiRendererProps<UiProps<'Summary'>>) {
|
|
36
|
+
const headId = useContext(UiHeadContext);
|
|
37
|
+
const head = headId === undefined || headId === node.id;
|
|
38
|
+
const { tone, badge } = node.props;
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex items-start gap-2 px-2">
|
|
41
|
+
{head && tone !== undefined && <Icon icon={UI_TONE_ICONS[tone]} size={16} className={clsx('mt-0.75 shrink-0', UI_TONE_TEXT[tone])} />}
|
|
42
|
+
<div className="min-w-0 grow text-sm font-semibold text-balance text-text">{children}</div>
|
|
43
|
+
{badge !== undefined && badge !== '' && (
|
|
44
|
+
<Pill shape="tag" tone={tone === undefined ? 'muted' : UI_TONE_PILL[tone]} className="shrink-0">
|
|
45
|
+
{badge}
|
|
46
|
+
</Pill>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function CalloutRenderer({ node, children }: UiRendererProps<UiProps<'Callout'>>) {
|
|
53
|
+
const { tone, title } = node.props;
|
|
54
|
+
return (
|
|
55
|
+
<div className={clsx('flex gap-2 rounded-md p-2 text-sm', UI_TONE_SURFACE[tone])}>
|
|
56
|
+
<Icon icon={UI_TONE_ICONS[tone]} size={16} className={clsx('mt-0.75 shrink-0', UI_TONE_TEXT[tone])} />
|
|
57
|
+
<div className="min-w-0 grow">
|
|
58
|
+
{title !== undefined && title !== '' && <div className="font-medium text-text">{title}</div>}
|
|
59
|
+
<div className="text-text-muted">{children}</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function TagRenderer({ node, children }: UiRendererProps<UiProps<'Tag'>>) {
|
|
66
|
+
const { tone } = node.props;
|
|
67
|
+
return (
|
|
68
|
+
<Pill shape="tag" size="sm" tone={tone === undefined ? 'muted' : UI_TONE_PILL[tone]}>
|
|
69
|
+
{children}
|
|
70
|
+
</Pill>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Without a value the progress is indeterminate; without a max the value is a percentage. */
|
|
75
|
+
export function ProgressRenderer({ node, children, context }: UiRendererProps<UiProps<'Progress'>>) {
|
|
76
|
+
const { t } = useTranslation('agent-chat');
|
|
77
|
+
const { value, max } = node.props;
|
|
78
|
+
const limit = max ?? 100;
|
|
79
|
+
const reading =
|
|
80
|
+
value === undefined ? null : limit === 100 ? formatPercent(value) : t('blocks.progress.of', { value: formatNumber(value), max: formatNumber(limit) });
|
|
81
|
+
const label = uiNodeLabel(node);
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex flex-col gap-1 px-2">
|
|
84
|
+
<div className="flex items-baseline justify-between gap-3 text-xs">
|
|
85
|
+
<span className="min-w-0 text-text">{children}</span>
|
|
86
|
+
{reading !== null ? (
|
|
87
|
+
<span className="shrink-0 text-text-muted tabular-nums">{reading}</span>
|
|
88
|
+
) : (
|
|
89
|
+
isLive(context) && <Spinner size={12} className="shrink-0 self-center text-text-faint" />
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
<Meter value={value ?? null} max={limit} label={label === '' ? t('blocks.progress.label') : label} valueText={reading ?? undefined} />
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function StepsRenderer({ children }: UiRendererProps<UiProps<'Steps'>>) {
|
|
98
|
+
return <ol className="chat-ui-nodes flex flex-col px-2">{children}</ol>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* A running step spins only while the block is live; in a quiet block it is a still dot. */
|
|
102
|
+
export function StepRenderer({ node, children, context }: UiRendererProps<UiProps<'Step'>>) {
|
|
103
|
+
const { t } = useTranslation('agent-chat');
|
|
104
|
+
const { state, detail } = node.props;
|
|
105
|
+
const quiet = state === 'pending' || state === 'skipped';
|
|
106
|
+
return (
|
|
107
|
+
<li className="flex min-h-7 items-center gap-2 text-sm">
|
|
108
|
+
<span className={clsx('grid size-4 shrink-0 place-items-center', STEP_TONES[state])}>
|
|
109
|
+
{state === 'running' ? (
|
|
110
|
+
isLive(context) ? (
|
|
111
|
+
<Spinner size={14} />
|
|
112
|
+
) : (
|
|
113
|
+
<Icon icon={CircleDot} size={14} />
|
|
114
|
+
)
|
|
115
|
+
) : (
|
|
116
|
+
<Icon icon={STEP_ICONS[state]} size={14} />
|
|
117
|
+
)}
|
|
118
|
+
</span>
|
|
119
|
+
<span className="sr-only">{t(`blocks.step.${state}`)}</span>
|
|
120
|
+
<span className={clsx('min-w-0 grow', quiet ? 'text-text-faint' : 'text-text')}>{children}</span>
|
|
121
|
+
{detail !== undefined && detail !== '' && <span className="shrink-0 text-xs text-text-faint tabular-nums">{detail}</span>}
|
|
122
|
+
</li>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { UiBlock } from '@adecore/intelligent-ui';
|
|
2
|
+
|
|
3
|
+
export type UiReplyPart = { kind: 'text'; text: string } | { kind: 'ui'; block: UiBlock };
|
|
4
|
+
|
|
5
|
+
export function uiReplyParts(text: string, blocks: readonly UiBlock[], streaming = false): UiReplyPart[] {
|
|
6
|
+
const parts: UiReplyPart[] = [];
|
|
7
|
+
let position = 0;
|
|
8
|
+
for (const block of [...blocks].sort((left, right) => left.start - right.start)) {
|
|
9
|
+
if (!Number.isInteger(block.start) || !Number.isInteger(block.end) || block.start < position || block.end < block.start || block.end > text.length) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (block.start > position) {
|
|
13
|
+
parts.push({ kind: 'text', text: text.slice(position, block.start) });
|
|
14
|
+
}
|
|
15
|
+
parts.push({ kind: 'ui', block });
|
|
16
|
+
position = streaming && !block.complete ? text.length : block.end;
|
|
17
|
+
}
|
|
18
|
+
if (position < text.length) {
|
|
19
|
+
parts.push({ kind: 'text', text: text.slice(position) });
|
|
20
|
+
}
|
|
21
|
+
return parts;
|
|
22
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { UiProps, UiViewNode } from '@adecore/intelligent-ui';
|
|
2
|
+
import { uiChildrenOf } from './node-text';
|
|
3
|
+
|
|
4
|
+
export type UiColumnKind = NonNullable<UiProps<'Column'>['as']>;
|
|
5
|
+
|
|
6
|
+
export interface UiTableColumn {
|
|
7
|
+
key: string;
|
|
8
|
+
title: string;
|
|
9
|
+
unit?: string;
|
|
10
|
+
as: UiColumnKind;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* What one cell shows, decided before drawing so a value of the wrong kind never reaches a formatter. */
|
|
14
|
+
export type UiTableCell =
|
|
15
|
+
| { kind: 'empty' }
|
|
16
|
+
| { kind: 'text'; text: string }
|
|
17
|
+
| { kind: 'number'; as: 'number' | 'bytes' | 'duration'; value: number }
|
|
18
|
+
| { kind: 'date'; at: number }
|
|
19
|
+
| { kind: 'file'; path: string }
|
|
20
|
+
| { kind: 'tag'; text: string };
|
|
21
|
+
|
|
22
|
+
/* Rows shown before a person asks for all of them. */
|
|
23
|
+
export const UI_TABLE_ROWS = 50;
|
|
24
|
+
/* Columns a table without Column children takes from its rows. */
|
|
25
|
+
const DERIVED_COLUMNS = 8;
|
|
26
|
+
const NUMERIC_KINDS: ReadonlySet<UiColumnKind> = new Set(['number', 'bytes', 'duration']);
|
|
27
|
+
|
|
28
|
+
export function isNumericColumn(column: UiTableColumn): boolean {
|
|
29
|
+
return NUMERIC_KINDS.has(column.as);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* The columns a table can draw: its Column children whose key some row has, each key once. A table
|
|
34
|
+
* without columns takes the keys of its rows, as numbers when every value there is one. Empty when
|
|
35
|
+
* nothing is usable, which draws the fallback.
|
|
36
|
+
*/
|
|
37
|
+
export function uiTableColumns(node: UiViewNode<UiProps<'Table'>>): UiTableColumn[] {
|
|
38
|
+
const rows = node.props.rows;
|
|
39
|
+
const present = (key: string): boolean => rows.some((row) => Object.hasOwn(row, key));
|
|
40
|
+
const declared = uiChildrenOf(node, 'Column');
|
|
41
|
+
if (declared.length > 0) {
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
return declared.flatMap(({ props }) => {
|
|
44
|
+
if (seen.has(props.key) || (rows.length > 0 && !present(props.key))) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
seen.add(props.key);
|
|
48
|
+
return [{ key: props.key, title: props.title ?? props.key, unit: props.unit, as: props.as ?? 'text' }];
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const keys: string[] = [];
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
for (const key of Object.keys(row)) {
|
|
54
|
+
if (!keys.includes(key) && keys.length < DERIVED_COLUMNS) {
|
|
55
|
+
keys.push(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return keys.map((key) => {
|
|
60
|
+
const values = rows.map((row) => row[key]).filter((value) => value !== undefined && value !== null);
|
|
61
|
+
const numeric = values.length > 0 && values.every((value) => typeof value === 'number' && Number.isFinite(value));
|
|
62
|
+
return { key, title: key, as: numeric ? 'number' : 'text' };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function textOf(value: unknown): string {
|
|
67
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function numberOf(value: unknown): number | null {
|
|
71
|
+
if (typeof value === 'number') {
|
|
72
|
+
return Number.isFinite(value) ? value : null;
|
|
73
|
+
}
|
|
74
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
75
|
+
const parsed = Number(value);
|
|
76
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* A duration is in milliseconds and a date is milliseconds since the epoch or an ISO string; anything else reads as text. */
|
|
82
|
+
export function uiTableCell(value: unknown, column: UiTableColumn): UiTableCell {
|
|
83
|
+
if (value === undefined || value === null || value === '') {
|
|
84
|
+
return { kind: 'empty' };
|
|
85
|
+
}
|
|
86
|
+
switch (column.as) {
|
|
87
|
+
case 'number':
|
|
88
|
+
case 'bytes':
|
|
89
|
+
case 'duration': {
|
|
90
|
+
const number = numberOf(value);
|
|
91
|
+
return number === null ? { kind: 'text', text: textOf(value) } : { kind: 'number', as: column.as, value: number };
|
|
92
|
+
}
|
|
93
|
+
case 'date': {
|
|
94
|
+
const at = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
|
95
|
+
return Number.isFinite(at) ? { kind: 'date', at } : { kind: 'text', text: textOf(value) };
|
|
96
|
+
}
|
|
97
|
+
case 'file':
|
|
98
|
+
return typeof value === 'string' ? { kind: 'file', path: value } : { kind: 'text', text: textOf(value) };
|
|
99
|
+
case 'tag':
|
|
100
|
+
return { kind: 'tag', text: textOf(value) };
|
|
101
|
+
case 'text':
|
|
102
|
+
return { kind: 'text', text: textOf(value) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CircleCheck, Info, MessageCircle, OctagonAlert, TriangleAlert, type LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { UiTone } from '@adecore/intelligent-ui';
|
|
3
|
+
import type { PillProps } from '@adecore/ui';
|
|
4
|
+
|
|
5
|
+
/* Every tone has an icon of its own, so color is never the only thing that tells two apart. */
|
|
6
|
+
export const UI_TONE_ICONS: Readonly<Record<UiTone, LucideIcon>> = {
|
|
7
|
+
neutral: MessageCircle,
|
|
8
|
+
info: Info,
|
|
9
|
+
success: CircleCheck,
|
|
10
|
+
warning: TriangleAlert,
|
|
11
|
+
danger: OctagonAlert
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const UI_TONE_TEXT: Readonly<Record<UiTone, string>> = {
|
|
15
|
+
neutral: 'text-text-muted',
|
|
16
|
+
info: 'text-status-running',
|
|
17
|
+
success: 'text-positive',
|
|
18
|
+
warning: 'text-status-needs-you',
|
|
19
|
+
danger: 'text-status-error'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/* The tone mixed over the ground, for a surface such as a callout. */
|
|
23
|
+
export const UI_TONE_SURFACE: Readonly<Record<UiTone, string>> = {
|
|
24
|
+
neutral: 'bg-text/7',
|
|
25
|
+
info: 'bg-status-running/10',
|
|
26
|
+
success: 'bg-positive/10',
|
|
27
|
+
warning: 'bg-status-needs-you/12',
|
|
28
|
+
danger: 'bg-status-error/10'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const UI_TONE_PILL: Readonly<Record<UiTone, NonNullable<PillProps['tone']>>> = {
|
|
32
|
+
neutral: 'muted',
|
|
33
|
+
info: 'accent',
|
|
34
|
+
success: 'idle',
|
|
35
|
+
warning: 'needsYou',
|
|
36
|
+
danger: 'error'
|
|
37
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/* True once `on` has held for `ms`, for a spinner that only shows when waiting takes long. */
|
|
4
|
+
export function useLater(on: boolean, ms: number): boolean {
|
|
5
|
+
const [late, setLate] = useState(false);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (!on) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const timer = setTimeout(() => setLate(true), ms);
|
|
11
|
+
return () => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
setLate(false);
|
|
14
|
+
};
|
|
15
|
+
}, [on, ms]);
|
|
16
|
+
return on && late;
|
|
17
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { ChatUiLinkReading, ChatUiQueryState } from '@adecore/agent-contracts';
|
|
3
|
+
import { uiLinkTargets, type UiLinkTarget } from '@adecore/intelligent-ui/links';
|
|
4
|
+
import { uiInputValues, type UiBlock, UiState } from '@adecore/intelligent-ui';
|
|
5
|
+
import { chatHost } from '../../../host';
|
|
6
|
+
import type { UiRenderContext } from './render-context';
|
|
7
|
+
|
|
8
|
+
interface LinkState {
|
|
9
|
+
target: string;
|
|
10
|
+
reading: ChatUiLinkReading;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useUiLinks(
|
|
14
|
+
block: UiBlock,
|
|
15
|
+
state: UiState,
|
|
16
|
+
context: UiRenderContext,
|
|
17
|
+
reads: Readonly<Record<string, string>>,
|
|
18
|
+
frozen?: ChatUiQueryState['blocks'][string]
|
|
19
|
+
) {
|
|
20
|
+
const [checked, setChecked] = useState<Record<string, LinkState>>({});
|
|
21
|
+
const host = chatHost().intelligentUi;
|
|
22
|
+
const values = uiInputValues(block, state);
|
|
23
|
+
const scope = state.scope();
|
|
24
|
+
const queryValues = Object.fromEntries(
|
|
25
|
+
Object.keys(block.queries)
|
|
26
|
+
.filter((name) => Object.hasOwn(scope, name))
|
|
27
|
+
.map((name) => [name, scope[name]])
|
|
28
|
+
);
|
|
29
|
+
let targets: Record<string, UiLinkTarget> = {};
|
|
30
|
+
try {
|
|
31
|
+
if (block.complete) {
|
|
32
|
+
targets = uiLinkTargets(block, values, queryValues);
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
35
|
+
const signature = JSON.stringify(targets);
|
|
36
|
+
const input = JSON.stringify(values);
|
|
37
|
+
const tickets = JSON.stringify(reads);
|
|
38
|
+
const link = host?.link;
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!link || !block.revision || context.phase !== 'final') {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let stopped = false;
|
|
44
|
+
const validate = async () => {
|
|
45
|
+
for (const [nodeId, target] of Object.entries(JSON.parse(signature) as Record<string, UiLinkTarget>).slice(0, 64)) {
|
|
46
|
+
try {
|
|
47
|
+
const reading = await link(context.scopeId, {
|
|
48
|
+
chatId: context.chatId,
|
|
49
|
+
itemId: context.itemId,
|
|
50
|
+
blockId: block.id,
|
|
51
|
+
revision: block.revision!,
|
|
52
|
+
nodeId,
|
|
53
|
+
values: JSON.parse(input),
|
|
54
|
+
reads: JSON.parse(tickets)
|
|
55
|
+
});
|
|
56
|
+
if (stopped) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading } }));
|
|
60
|
+
} catch {
|
|
61
|
+
if (stopped) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading: { state: 'plain' } } }));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
void validate();
|
|
69
|
+
return () => {
|
|
70
|
+
stopped = true;
|
|
71
|
+
};
|
|
72
|
+
}, [block.id, block.revision, context.scopeId, context.chatId, context.itemId, context.phase, signature, input, tickets, link]);
|
|
73
|
+
const idOf = (target: UiLinkTarget) => Object.entries(targets).find(([, entry]) => JSON.stringify(entry) === JSON.stringify(target))?.[0];
|
|
74
|
+
const resolution = (target: UiLinkTarget): ChatUiLinkReading => {
|
|
75
|
+
const id = idOf(target);
|
|
76
|
+
if (id === undefined) {
|
|
77
|
+
return { state: 'plain' };
|
|
78
|
+
}
|
|
79
|
+
if (checked[id]?.target === JSON.stringify(target)) {
|
|
80
|
+
return checked[id].reading;
|
|
81
|
+
}
|
|
82
|
+
if (input === JSON.stringify(uiInputValues(block, new UiState(block))) && frozen && frozen.revision === block.revision) {
|
|
83
|
+
const firstQueries = Object.fromEntries(
|
|
84
|
+
Object.entries(frozen.readings)
|
|
85
|
+
.filter(([, reading]) => reading.state === 'fresh')
|
|
86
|
+
.map(([name, reading]) => [name, reading.value])
|
|
87
|
+
);
|
|
88
|
+
if (JSON.stringify(queryValues) === JSON.stringify(firstQueries)) {
|
|
89
|
+
return frozen.links?.[id] ?? { state: 'plain' };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { state: 'plain' };
|
|
93
|
+
};
|
|
94
|
+
const open = (target: UiLinkTarget) => {
|
|
95
|
+
const nodeId = idOf(target);
|
|
96
|
+
if (!nodeId || !link || !block.revision) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
void link(context.scopeId, {
|
|
100
|
+
chatId: context.chatId,
|
|
101
|
+
itemId: context.itemId,
|
|
102
|
+
blockId: block.id,
|
|
103
|
+
revision: block.revision,
|
|
104
|
+
nodeId,
|
|
105
|
+
values,
|
|
106
|
+
reads: { ...reads }
|
|
107
|
+
})
|
|
108
|
+
.then((reading) => {
|
|
109
|
+
setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading } }));
|
|
110
|
+
if (reading.state === 'chip' && reading.target) {
|
|
111
|
+
host?.openLink?.(context.scopeId, reading);
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.catch(() => {
|
|
115
|
+
setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading: { state: 'plain' } } }));
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
link: link ? resolution : context.link,
|
|
120
|
+
openLink: link && host?.openLink ? open : context.openLink,
|
|
121
|
+
openUrl: host?.openUrl ? (url: string) => host.openUrl!(context.scopeId, url) : context.openUrl
|
|
122
|
+
};
|
|
123
|
+
}
|