@nocobase/portal-template-default 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/package.json +1 -1
  2. package/scripts/registry.mjs +2 -0
  3. package/src/extensions/nocobase-acl/README.md +19 -0
  4. package/src/extensions/nocobase-acl/components/acl-boundary.tsx +106 -0
  5. package/src/extensions/nocobase-acl/components/index.ts +3 -0
  6. package/src/extensions/nocobase-acl/components/role-options.ts +36 -0
  7. package/src/extensions/nocobase-acl/components/role-switcher.tsx +135 -0
  8. package/src/extensions/nocobase-acl/demo/boundary-api.tsx +186 -0
  9. package/src/extensions/nocobase-acl/demo/components.tsx +231 -0
  10. package/src/extensions/nocobase-acl/demo/index.tsx +505 -0
  11. package/src/extensions/nocobase-acl/demo/lazy-route.tsx +10 -0
  12. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +134 -0
  13. package/src/extensions/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
  14. package/src/extensions/nocobase-acl/demo/scenario-section.tsx +37 -0
  15. package/src/extensions/nocobase-acl/extension.tsx +71 -0
  16. package/src/extensions/nocobase-acl/index.ts +1 -0
  17. package/src/extensions/nocobase-ai/README.md +70 -0
  18. package/src/extensions/nocobase-ai/adapters/react-hook-form.ts +20 -0
  19. package/src/extensions/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
  20. package/src/extensions/nocobase-ai/components/ai-root-provider.tsx +32 -0
  21. package/src/extensions/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
  22. package/src/extensions/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
  23. package/src/extensions/nocobase-ai/components/chat/chat-compact.tsx +90 -0
  24. package/src/extensions/nocobase-ai/components/chat/chat-composer.tsx +429 -0
  25. package/src/extensions/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
  26. package/src/extensions/nocobase-ai/components/chat/chat-header.tsx +88 -0
  27. package/src/extensions/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
  28. package/src/extensions/nocobase-ai/components/chat/chat-message.tsx +252 -0
  29. package/src/extensions/nocobase-ai/components/chat/chat-messages.tsx +173 -0
  30. package/src/extensions/nocobase-ai/components/chat/chat-window.tsx +118 -0
  31. package/src/extensions/nocobase-ai/components/chat/conversation-list.tsx +343 -0
  32. package/src/extensions/nocobase-ai/components/chat/markdown-message.tsx +165 -0
  33. package/src/extensions/nocobase-ai/components/chat/model-select-options.tsx +30 -0
  34. package/src/extensions/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
  35. package/src/extensions/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
  36. package/src/extensions/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
  37. package/src/extensions/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
  38. package/src/extensions/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
  39. package/src/extensions/nocobase-ai/components/index.ts +59 -0
  40. package/src/extensions/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
  41. package/src/extensions/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
  42. package/src/extensions/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
  43. package/src/extensions/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
  44. package/src/extensions/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
  45. package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
  46. package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
  47. package/src/extensions/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
  48. package/src/extensions/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
  49. package/src/extensions/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
  50. package/src/extensions/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
  51. package/src/extensions/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
  52. package/src/extensions/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
  53. package/src/extensions/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
  54. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
  55. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
  56. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
  57. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
  58. package/src/extensions/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
  59. package/src/extensions/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
  60. package/src/extensions/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
  61. package/src/extensions/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
  62. package/src/extensions/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
  63. package/src/extensions/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
  64. package/src/extensions/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
  65. package/src/extensions/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
  66. package/src/extensions/nocobase-ai/demo/configuration-gate.tsx +49 -0
  67. package/src/extensions/nocobase-ai/demo/container-showcase.tsx +210 -0
  68. package/src/extensions/nocobase-ai/demo/floating.tsx +134 -0
  69. package/src/extensions/nocobase-ai/demo/index.tsx +346 -0
  70. package/src/extensions/nocobase-ai/demo/interaction-showcase.tsx +151 -0
  71. package/src/extensions/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
  72. package/src/extensions/nocobase-ai/demo/page-context.tsx +884 -0
  73. package/src/extensions/nocobase-ai/demo/page-element-showcase.tsx +197 -0
  74. package/src/extensions/nocobase-ai/demo/prompt-card.tsx +41 -0
  75. package/src/extensions/nocobase-ai/demo/prompt-generator.tsx +452 -0
  76. package/src/extensions/nocobase-ai/demo/shortcut.tsx +1207 -0
  77. package/src/extensions/nocobase-ai/demo/tool-cards.tsx +530 -0
  78. package/src/extensions/nocobase-ai/extension.tsx +158 -0
  79. package/src/extensions/nocobase-ai/global-ai-chat.tsx +213 -0
  80. package/src/extensions/nocobase-ai/index.ts +3 -0
  81. package/src/extensions/nocobase-ai/providers/ai-provider.tsx +323 -0
  82. package/src/extensions/nocobase-ai/providers/avatars.ts +817 -0
  83. package/src/extensions/nocobase-ai/providers/chat-context.tsx +109 -0
  84. package/src/extensions/nocobase-ai/providers/chat-controller.ts +84 -0
  85. package/src/extensions/nocobase-ai/providers/chat-message-utils.ts +198 -0
  86. package/src/extensions/nocobase-ai/providers/chat-provider.tsx +871 -0
  87. package/src/extensions/nocobase-ai/providers/chat-reducer.ts +147 -0
  88. package/src/extensions/nocobase-ai/providers/chat-task-utils.ts +105 -0
  89. package/src/extensions/nocobase-ai/providers/chat-transport.ts +184 -0
  90. package/src/extensions/nocobase-ai/providers/form-registry.tsx +271 -0
  91. package/src/extensions/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
  92. package/src/extensions/nocobase-ai/providers/index.ts +61 -0
  93. package/src/extensions/nocobase-ai/providers/model.ts +32 -0
  94. package/src/extensions/nocobase-ai/providers/page-context.tsx +136 -0
  95. package/src/extensions/nocobase-ai/providers/stream-coalescer.ts +60 -0
  96. package/src/extensions/nocobase-ai/providers/stream-event-utils.ts +82 -0
  97. package/src/extensions/nocobase-ai/providers/stream-parser.ts +61 -0
  98. package/src/extensions/nocobase-ai/providers/sub-agent-stream.ts +316 -0
  99. package/src/extensions/nocobase-ai/providers/types.ts +183 -0
  100. package/src/extensions/nocobase-ai/providers/ui-message-stream.ts +463 -0
  101. package/src/extensions/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
  102. package/src/extensions/nocobase-ai/providers/use-chat-attachments.ts +206 -0
  103. package/src/extensions/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
  104. package/src/extensions/nocobase-ai/providers/use-chat-runtime.ts +210 -0
  105. package/src/extensions/nocobase-ai/providers/use-chat-work-context.ts +93 -0
  106. package/src/extensions/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
  107. package/src/extensions/nocobase-ai/providers/use-conversation-history.ts +178 -0
  108. package/src/extensions/nocobase-ai/services/index.ts +7 -0
  109. package/src/extensions/nocobase-ai/services/nocobase-ai-service.ts +454 -0
  110. package/src/extensions/nocobase-ai/services/types.ts +82 -0
@@ -0,0 +1,74 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import { cn } from "@/lib/utils";
3
+ import { PanelLeftClose, PanelRightClose } from "lucide-react";
4
+ import { useEffect, type CSSProperties, type PropsWithChildren } from "react";
5
+
6
+ export type ChatSidePanelProps = PropsWithChildren<{
7
+ open: boolean;
8
+ onOpenChange: (open: boolean) => void;
9
+ side?: "left" | "right";
10
+ width?: number | string;
11
+ closeOnEscape?: boolean;
12
+ showCloseHandle?: boolean;
13
+ }>;
14
+
15
+ export function ChatSidePanel({
16
+ open,
17
+ onOpenChange,
18
+ side = "right",
19
+ width = 450,
20
+ closeOnEscape = true,
21
+ showCloseHandle = true,
22
+ children,
23
+ }: ChatSidePanelProps) {
24
+ useEffect(() => {
25
+ if (!open || !closeOnEscape) return;
26
+ const onKeyDown = (event: KeyboardEvent) => {
27
+ if (event.key !== "Escape" || event.defaultPrevented) return;
28
+ const openDialog = document.querySelector(
29
+ '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
30
+ );
31
+ if (openDialog) return;
32
+ onOpenChange(false);
33
+ };
34
+ window.addEventListener("keydown", onKeyDown);
35
+ return () => window.removeEventListener("keydown", onKeyDown);
36
+ }, [closeOnEscape, onOpenChange, open]);
37
+
38
+ if (!open) return null;
39
+
40
+ const panelWidth = typeof width === "number" ? `${width}px` : width;
41
+
42
+ return (
43
+ <aside
44
+ role="complementary"
45
+ aria-label="NocoBase AI employee"
46
+ data-side={side}
47
+ className={cn(
48
+ "fixed inset-y-0 z-50 max-w-full overscroll-contain bg-background shadow-2xl animate-in duration-200",
49
+ side === "right"
50
+ ? "right-0 border-l slide-in-from-right"
51
+ : "left-0 border-r slide-in-from-left"
52
+ )}
53
+ style={{ width: panelWidth } as CSSProperties}
54
+ >
55
+ {showCloseHandle ? (
56
+ <Button
57
+ variant="outline"
58
+ size="icon-sm"
59
+ className={cn(
60
+ "absolute top-1/2 z-40 size-9 -translate-y-1/2 rounded-full bg-background shadow-md before:absolute before:-inset-2",
61
+ side === "right"
62
+ ? "left-0 -translate-x-1/2"
63
+ : "right-0 translate-x-1/2"
64
+ )}
65
+ aria-label="Close side panel"
66
+ onClick={() => onOpenChange(false)}
67
+ >
68
+ {side === "right" ? <PanelRightClose /> : <PanelLeftClose />}
69
+ </Button>
70
+ ) : null}
71
+ {children}
72
+ </aside>
73
+ );
74
+ }
@@ -0,0 +1,54 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import {
3
+ Tooltip,
4
+ TooltipContent,
5
+ TooltipTrigger,
6
+ } from "@/components/ui/tooltip";
7
+ import { Maximize2, PanelRight, X } from "lucide-react";
8
+
9
+ export function ChatSurfaceActions({
10
+ expanded,
11
+ onExpandedChange,
12
+ onClose,
13
+ }: {
14
+ expanded: boolean;
15
+ onExpandedChange: (expanded: boolean) => void;
16
+ onClose: () => void;
17
+ }) {
18
+ return (
19
+ <>
20
+ <Tooltip>
21
+ <TooltipTrigger
22
+ render={
23
+ <Button
24
+ variant="ghost"
25
+ size="icon-sm"
26
+ aria-label={expanded ? "Collapse to side panel" : "Expand panel"}
27
+ onClick={() => onExpandedChange(!expanded)}
28
+ />
29
+ }
30
+ >
31
+ {expanded ? <PanelRight /> : <Maximize2 />}
32
+ </TooltipTrigger>
33
+ <TooltipContent>
34
+ {expanded ? "Collapse to side panel" : "Expand panel"}
35
+ </TooltipContent>
36
+ </Tooltip>
37
+ <Tooltip>
38
+ <TooltipTrigger
39
+ render={
40
+ <Button
41
+ variant="ghost"
42
+ size="icon-sm"
43
+ aria-label="Close AI chat"
44
+ onClick={onClose}
45
+ />
46
+ }
47
+ >
48
+ <X />
49
+ </TooltipTrigger>
50
+ <TooltipContent>Close</TooltipContent>
51
+ </Tooltip>
52
+ </>
53
+ );
54
+ }
@@ -0,0 +1,31 @@
1
+ import type { AIToolRendererMap } from "./tool-renderer-provider";
2
+ import { BusinessReportRenderer } from "./business-report-renderer";
3
+ import { ChartRenderer } from "./chart-renderer";
4
+ import { SubAgentRenderer } from "./sub-agent-renderer";
5
+ import { SuggestionsRenderer } from "./suggestions-renderer";
6
+ import { WorkflowRenderer } from "./workflow-renderer";
7
+
8
+ export const builtInToolRenderers: AIToolRendererMap = {
9
+ suggestions: {
10
+ component: SuggestionsRenderer,
11
+ handlesApproval: true,
12
+ standalone: true,
13
+ },
14
+ businessReportGenerator: {
15
+ component: BusinessReportRenderer,
16
+ standalone: true,
17
+ },
18
+ chartGenerator: {
19
+ component: ChartRenderer,
20
+ standalone: true,
21
+ },
22
+ "dispatch-sub-agent-task": {
23
+ component: SubAgentRenderer,
24
+ standalone: true,
25
+ },
26
+ aiEmployeeWorkflowTaskOutput: {
27
+ component: WorkflowRenderer,
28
+ handlesApproval: true,
29
+ standalone: true,
30
+ },
31
+ };
@@ -0,0 +1,364 @@
1
+ import { MarkdownMessage } from "../chat/markdown-message";
2
+ import { LoadingState } from "@/components/app-shell/loading-state";
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogTitle,
9
+ } from "@/components/ui/dialog";
10
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
11
+ import { Download, FileCode2, LoaderCircle, Printer } from "lucide-react";
12
+ import {
13
+ createContext,
14
+ lazy,
15
+ type PropsWithChildren,
16
+ Suspense,
17
+ useCallback,
18
+ useContext,
19
+ useEffect,
20
+ useMemo,
21
+ useState,
22
+ } from "react";
23
+ import {
24
+ buildBusinessReportHtml,
25
+ buildBusinessReportMarkdown,
26
+ downloadBusinessReportFile,
27
+ getBusinessReportFileName,
28
+ printBusinessReport,
29
+ splitBusinessReportMarkdown,
30
+ type BusinessReportData,
31
+ } from "./business-report-utils";
32
+
33
+ type BusinessReportDialogSnapshot = {
34
+ open: boolean;
35
+ toolCallId?: string;
36
+ report?: BusinessReportData;
37
+ ready: boolean;
38
+ };
39
+
40
+ const closedSnapshot: BusinessReportDialogSnapshot = {
41
+ open: false,
42
+ ready: false,
43
+ };
44
+ const EChartsPreview = lazy(() => import("./echarts-preview"));
45
+
46
+ const sameCharts = (
47
+ left: BusinessReportData["charts"],
48
+ right: BusinessReportData["charts"]
49
+ ) => left === right || JSON.stringify(left) === JSON.stringify(right);
50
+
51
+ const sameReport = (
52
+ left: BusinessReportData | undefined,
53
+ right: BusinessReportData
54
+ ) =>
55
+ left?.title === right.title &&
56
+ left.summary === right.summary &&
57
+ left.markdown === right.markdown &&
58
+ left.fileName === right.fileName &&
59
+ sameCharts(left.charts, right.charts);
60
+
61
+ type BusinessReportDialogController = {
62
+ open: (
63
+ toolCallId: string,
64
+ report: BusinessReportData,
65
+ ready: boolean
66
+ ) => void;
67
+ update: (
68
+ toolCallId: string,
69
+ report: BusinessReportData,
70
+ ready: boolean
71
+ ) => void;
72
+ };
73
+
74
+ const BusinessReportDialogContext =
75
+ createContext<BusinessReportDialogController | null>(null);
76
+
77
+ export function useBusinessReportDialog() {
78
+ const value = useContext(BusinessReportDialogContext);
79
+ if (!value) {
80
+ throw new Error(
81
+ "useBusinessReportDialog must be used inside BusinessReportDialogProvider"
82
+ );
83
+ }
84
+ return value;
85
+ }
86
+
87
+ export function BusinessReportDialogProvider({ children }: PropsWithChildren) {
88
+ const [state, setState] = useState(closedSnapshot);
89
+ const open = useCallback(
90
+ (toolCallId: string, report: BusinessReportData, ready: boolean) =>
91
+ setState({ open: true, toolCallId, report, ready }),
92
+ []
93
+ );
94
+ const update = useCallback(
95
+ (toolCallId: string, report: BusinessReportData, ready: boolean) =>
96
+ setState((current) => {
97
+ if (current.toolCallId !== toolCallId) return current;
98
+ if (current.ready === ready && sameReport(current.report, report)) {
99
+ return current;
100
+ }
101
+ return { ...current, report, ready };
102
+ }),
103
+ []
104
+ );
105
+ const controller = useMemo(() => ({ open, update }), [open, update]);
106
+ return (
107
+ <BusinessReportDialogContext.Provider value={controller}>
108
+ {children}
109
+ <BusinessReportDialogHost
110
+ state={state}
111
+ onOpenChange={(nextOpen) =>
112
+ setState((current) =>
113
+ current.open === nextOpen ? current : { ...current, open: nextOpen }
114
+ )
115
+ }
116
+ />
117
+ </BusinessReportDialogContext.Provider>
118
+ );
119
+ }
120
+
121
+ function ChartPreview({ options }: { options: Record<string, unknown> }) {
122
+ return (
123
+ <Suspense fallback={<LoadingState className="h-[280px]" />}>
124
+ <EChartsPreview options={options} />
125
+ </Suspense>
126
+ );
127
+ }
128
+
129
+ function BusinessReportDialogHost({
130
+ state,
131
+ onOpenChange,
132
+ }: {
133
+ state: BusinessReportDialogSnapshot;
134
+ onOpenChange: (open: boolean) => void;
135
+ }) {
136
+ const report = state.report;
137
+ const [activeTab, setActiveTab] = useState("preview");
138
+ const [htmlPreview, setHtmlPreview] = useState("");
139
+ const [htmlPreviewSignature, setHtmlPreviewSignature] = useState("");
140
+ const [htmlLoading, setHtmlLoading] = useState(false);
141
+ const [exporting, setExporting] = useState<"html" | "pdf">();
142
+ const [exportError, setExportError] = useState<string>();
143
+ const reportSignature = useMemo(
144
+ () => (state.ready && report ? JSON.stringify(report) : ""),
145
+ [report, state.ready]
146
+ );
147
+ const reportMarkdown = useMemo(
148
+ () =>
149
+ state.open && state.ready && report
150
+ ? buildBusinessReportMarkdown(report)
151
+ : "",
152
+ [report, state.open, state.ready]
153
+ );
154
+ const previewParts = useMemo(
155
+ () =>
156
+ activeTab === "preview" && reportMarkdown
157
+ ? splitBusinessReportMarkdown(reportMarkdown)
158
+ : [],
159
+ [activeTab, reportMarkdown]
160
+ );
161
+
162
+ useEffect(() => {
163
+ setActiveTab("preview");
164
+ setHtmlPreview("");
165
+ setHtmlPreviewSignature("");
166
+ setExportError(undefined);
167
+ }, [state.toolCallId]);
168
+
169
+ useEffect(() => {
170
+ if (
171
+ !state.open ||
172
+ activeTab !== "html" ||
173
+ !state.ready ||
174
+ !report?.markdown ||
175
+ (htmlPreview && htmlPreviewSignature === reportSignature)
176
+ ) {
177
+ return;
178
+ }
179
+ let active = true;
180
+ setHtmlLoading(true);
181
+ setHtmlPreview("");
182
+ void buildBusinessReportHtml(report)
183
+ .then((html) => {
184
+ if (!active) return;
185
+ setHtmlPreview(html);
186
+ setHtmlPreviewSignature(reportSignature);
187
+ })
188
+ .catch((error: unknown) => {
189
+ if (active) {
190
+ setExportError(
191
+ error instanceof Error ? error.message : "Unable to build HTML"
192
+ );
193
+ }
194
+ })
195
+ .finally(() => {
196
+ if (active) setHtmlLoading(false);
197
+ });
198
+ return () => {
199
+ active = false;
200
+ };
201
+ }, [
202
+ activeTab,
203
+ htmlPreview,
204
+ htmlPreviewSignature,
205
+ report,
206
+ reportSignature,
207
+ state.open,
208
+ state.ready,
209
+ ]);
210
+
211
+ if (!report) return null;
212
+ const summary =
213
+ report.summary || "Open the report to review the generated analysis.";
214
+ const fileName = getBusinessReportFileName(report);
215
+
216
+ return (
217
+ <Dialog open={state.open} onOpenChange={onOpenChange}>
218
+ <DialogContent className="h-[86svh] w-[min(980px,calc(100vw-2rem))] max-w-[980px] grid-rows-[auto_1fr_auto] overflow-hidden p-0 sm:max-w-[980px]">
219
+ <div className="border-b px-5 py-4">
220
+ <DialogTitle>{report.title}</DialogTitle>
221
+ <DialogDescription className="mt-1">{summary}</DialogDescription>
222
+ </div>
223
+ <Tabs
224
+ value={activeTab}
225
+ onValueChange={(value) => setActiveTab(String(value))}
226
+ className="min-h-0 overflow-hidden px-5 py-4"
227
+ >
228
+ <TabsList>
229
+ <TabsTrigger value="preview">Preview</TabsTrigger>
230
+ <TabsTrigger value="markdown">Markdown</TabsTrigger>
231
+ <TabsTrigger value="html">HTML</TabsTrigger>
232
+ </TabsList>
233
+ <TabsContent
234
+ value="preview"
235
+ className="mt-3 min-h-0 overflow-auto rounded-lg border bg-background p-5"
236
+ >
237
+ <div className="space-y-4">
238
+ {previewParts.map((item, index) =>
239
+ item.type === "markdown" ? (
240
+ <div key={index} className="ai-markdown">
241
+ <MarkdownMessage variant="document">
242
+ {item.content}
243
+ </MarkdownMessage>
244
+ </div>
245
+ ) : (
246
+ <div key={index} className="rounded-lg border p-3">
247
+ <ChartPreview options={item.options} />
248
+ </div>
249
+ )
250
+ )}
251
+ </div>
252
+ </TabsContent>
253
+ <TabsContent
254
+ value="markdown"
255
+ className="mt-3 min-h-0 overflow-auto rounded-lg bg-muted p-4"
256
+ >
257
+ <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">
258
+ {reportMarkdown}
259
+ </pre>
260
+ </TabsContent>
261
+ <TabsContent
262
+ value="html"
263
+ className="mt-3 min-h-0 overflow-hidden rounded-lg border bg-background"
264
+ >
265
+ {htmlPreview ? (
266
+ <iframe
267
+ title={`${report.title} HTML preview`}
268
+ srcDoc={htmlPreview}
269
+ className="size-full min-h-[480px] border-0 bg-white"
270
+ />
271
+ ) : htmlLoading ? (
272
+ <LoadingState className="h-full min-h-[480px]" />
273
+ ) : (
274
+ <div className="flex h-full min-h-[480px] items-center justify-center gap-2 text-sm text-muted-foreground">
275
+ HTML is unavailable
276
+ </div>
277
+ )}
278
+ </TabsContent>
279
+ </Tabs>
280
+ <div className="flex flex-wrap items-center justify-end gap-2 border-t px-5 py-3">
281
+ {exportError ? (
282
+ <p className="mr-auto text-xs text-destructive">{exportError}</p>
283
+ ) : null}
284
+ <Button
285
+ variant="outline"
286
+ disabled={!report.markdown}
287
+ onClick={() =>
288
+ downloadBusinessReportFile(
289
+ `${fileName}.md`,
290
+ reportMarkdown,
291
+ "text/markdown;charset=utf-8"
292
+ )
293
+ }
294
+ >
295
+ <Download /> Download Markdown
296
+ </Button>
297
+ <Button
298
+ variant="outline"
299
+ disabled={!report.markdown || exporting !== undefined}
300
+ onClick={async () => {
301
+ setExportError(undefined);
302
+ setExporting("html");
303
+ try {
304
+ const html = await buildBusinessReportHtml(report, {
305
+ printMode: true,
306
+ });
307
+ downloadBusinessReportFile(
308
+ `${fileName}.html`,
309
+ html,
310
+ "text/html;charset=utf-8"
311
+ );
312
+ } catch (error) {
313
+ setExportError(
314
+ error instanceof Error
315
+ ? error.message
316
+ : "Unable to export HTML"
317
+ );
318
+ } finally {
319
+ setExporting(undefined);
320
+ }
321
+ }}
322
+ >
323
+ {exporting === "html" ? (
324
+ <LoaderCircle className="animate-spin" />
325
+ ) : (
326
+ <FileCode2 />
327
+ )}
328
+ Download HTML
329
+ </Button>
330
+ <Button
331
+ disabled={!report.markdown || exporting !== undefined}
332
+ onClick={async () => {
333
+ setExportError(undefined);
334
+ setExporting("pdf");
335
+ try {
336
+ const opened = await printBusinessReport(report);
337
+ if (!opened) {
338
+ setExportError(
339
+ "Popup blocked. Allow popups and try printing again."
340
+ );
341
+ }
342
+ } catch (error) {
343
+ setExportError(
344
+ error instanceof Error
345
+ ? error.message
346
+ : "Unable to print report"
347
+ );
348
+ } finally {
349
+ setExporting(undefined);
350
+ }
351
+ }}
352
+ >
353
+ {exporting === "pdf" ? (
354
+ <LoaderCircle className="animate-spin" />
355
+ ) : (
356
+ <Printer />
357
+ )}
358
+ Print PDF
359
+ </Button>
360
+ </div>
361
+ </DialogContent>
362
+ </Dialog>
363
+ );
364
+ }
@@ -0,0 +1,122 @@
1
+ import { Badge } from "@/components/ui/badge";
2
+ import { cn } from "@/lib/utils";
3
+ import { FileText, LoaderCircle } from "lucide-react";
4
+ import { useEffect, useMemo, useRef } from "react";
5
+ import { getNocoBaseToolCallMetadata } from "../chat/tool-call-card";
6
+ import type { AIToolRendererProps } from "./tool-renderer-provider";
7
+ import {
8
+ normalizeBusinessReportCharts,
9
+ type BusinessReportData,
10
+ } from "./business-report-utils";
11
+ import {
12
+ useBusinessReportDialog,
13
+ } from "./business-report-dialog";
14
+ import { asRecord, asString } from "./tool-renderer-utils";
15
+
16
+ function ReportGeneratingProgress() {
17
+ return (
18
+ <div
19
+ role="progressbar"
20
+ aria-label="Generating business report"
21
+ className="mt-3 h-1 w-full overflow-hidden rounded-full bg-muted"
22
+ >
23
+ <svg
24
+ viewBox="0 0 100 4"
25
+ preserveAspectRatio="none"
26
+ aria-hidden="true"
27
+ className="block size-full motion-reduce:hidden"
28
+ >
29
+ <rect x="-28" y="0" width="28" height="4" rx="2" className="fill-primary">
30
+ <animate
31
+ attributeName="x"
32
+ values="-28;100"
33
+ dur="1.35s"
34
+ repeatCount="indefinite"
35
+ />
36
+ </rect>
37
+ </svg>
38
+ <div className="hidden h-full w-2/3 rounded-full bg-primary motion-reduce:block" />
39
+ </div>
40
+ );
41
+ }
42
+
43
+ export function BusinessReportRenderer({ part }: AIToolRendererProps) {
44
+ const reportDialog = useBusinessReportDialog();
45
+ const input = asRecord(part.input);
46
+ const title = asString(input.title) || "Business analysis report";
47
+ const reportSummary = asString(input.summary);
48
+ const summary =
49
+ reportSummary || "Open the report to review the generated analysis.";
50
+ const charts = useMemo(
51
+ () => normalizeBusinessReportCharts(input.charts),
52
+ [input.charts]
53
+ );
54
+ const report = useMemo<BusinessReportData>(
55
+ () => ({
56
+ title,
57
+ summary: reportSummary || undefined,
58
+ markdown: asString(input.markdown),
59
+ charts,
60
+ fileName: asString(input.fileName) || undefined,
61
+ }),
62
+ [charts, input.fileName, input.markdown, reportSummary, title]
63
+ );
64
+ const metadata = getNocoBaseToolCallMetadata(part);
65
+ const ready =
66
+ part.state === "output-available" ||
67
+ (metadata?.status === "success" &&
68
+ ["done", "confirmed"].includes(metadata.invokeStatus ?? ""));
69
+ const generating = !ready && part.state !== "output-error";
70
+ const wasGenerating = useRef(false);
71
+
72
+ useEffect(() => {
73
+ reportDialog.update(part.toolCallId, report, ready);
74
+ }, [part.toolCallId, ready, report, reportDialog]);
75
+
76
+ useEffect(() => {
77
+ if (generating) {
78
+ wasGenerating.current = true;
79
+ return;
80
+ }
81
+ if (wasGenerating.current && ready) {
82
+ wasGenerating.current = false;
83
+ reportDialog.open(part.toolCallId, report, ready);
84
+ }
85
+ }, [generating, part.toolCallId, ready, report, reportDialog]);
86
+
87
+ return (
88
+ <button
89
+ type="button"
90
+ className={cn(
91
+ "w-full rounded-lg border bg-background p-3 text-left transition-colors",
92
+ ready && "hover:bg-muted/40"
93
+ )}
94
+ disabled={!ready}
95
+ onClick={() => reportDialog.open(part.toolCallId, report, ready)}
96
+ >
97
+ <div className="flex items-start gap-3">
98
+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/30">
99
+ {generating ? (
100
+ <LoaderCircle className="size-4 animate-spin" />
101
+ ) : (
102
+ <FileText className="size-4" />
103
+ )}
104
+ </div>
105
+ <div className="min-w-0 flex-1">
106
+ <div className="font-medium">{title}</div>
107
+ <p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
108
+ {summary}
109
+ </p>
110
+ <div className="mt-2 flex flex-wrap gap-1.5">
111
+ <Badge variant="secondary">
112
+ {generating ? "Generating" : "Markdown"}
113
+ </Badge>
114
+ <Badge variant="outline">{charts.length} charts</Badge>
115
+ <Badge variant="outline">Preview and export</Badge>
116
+ </div>
117
+ {generating ? <ReportGeneratingProgress /> : null}
118
+ </div>
119
+ </div>
120
+ </button>
121
+ );
122
+ }