@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.
- package/package.json +1 -1
- package/scripts/registry.mjs +2 -0
- package/src/extensions/nocobase-acl/README.md +19 -0
- package/src/extensions/nocobase-acl/components/acl-boundary.tsx +106 -0
- package/src/extensions/nocobase-acl/components/index.ts +3 -0
- package/src/extensions/nocobase-acl/components/role-options.ts +36 -0
- package/src/extensions/nocobase-acl/components/role-switcher.tsx +135 -0
- package/src/extensions/nocobase-acl/demo/boundary-api.tsx +186 -0
- package/src/extensions/nocobase-acl/demo/components.tsx +231 -0
- package/src/extensions/nocobase-acl/demo/index.tsx +505 -0
- package/src/extensions/nocobase-acl/demo/lazy-route.tsx +10 -0
- package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +134 -0
- package/src/extensions/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
- package/src/extensions/nocobase-acl/demo/scenario-section.tsx +37 -0
- package/src/extensions/nocobase-acl/extension.tsx +71 -0
- package/src/extensions/nocobase-acl/index.ts +1 -0
- package/src/extensions/nocobase-ai/README.md +70 -0
- package/src/extensions/nocobase-ai/adapters/react-hook-form.ts +20 -0
- package/src/extensions/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
- package/src/extensions/nocobase-ai/components/ai-root-provider.tsx +32 -0
- package/src/extensions/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
- package/src/extensions/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
- package/src/extensions/nocobase-ai/components/chat/chat-compact.tsx +90 -0
- package/src/extensions/nocobase-ai/components/chat/chat-composer.tsx +429 -0
- package/src/extensions/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
- package/src/extensions/nocobase-ai/components/chat/chat-header.tsx +88 -0
- package/src/extensions/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
- package/src/extensions/nocobase-ai/components/chat/chat-message.tsx +252 -0
- package/src/extensions/nocobase-ai/components/chat/chat-messages.tsx +173 -0
- package/src/extensions/nocobase-ai/components/chat/chat-window.tsx +118 -0
- package/src/extensions/nocobase-ai/components/chat/conversation-list.tsx +343 -0
- package/src/extensions/nocobase-ai/components/chat/markdown-message.tsx +165 -0
- package/src/extensions/nocobase-ai/components/chat/model-select-options.tsx +30 -0
- package/src/extensions/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
- package/src/extensions/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
- package/src/extensions/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
- package/src/extensions/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
- package/src/extensions/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
- package/src/extensions/nocobase-ai/components/index.ts +59 -0
- package/src/extensions/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
- package/src/extensions/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
- package/src/extensions/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
- package/src/extensions/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
- package/src/extensions/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
- package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
- package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
- package/src/extensions/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
- package/src/extensions/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
- package/src/extensions/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
- package/src/extensions/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
- package/src/extensions/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
- package/src/extensions/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
- package/src/extensions/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
- package/src/extensions/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
- package/src/extensions/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
- package/src/extensions/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
- package/src/extensions/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
- package/src/extensions/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
- package/src/extensions/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
- package/src/extensions/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
- package/src/extensions/nocobase-ai/demo/configuration-gate.tsx +49 -0
- package/src/extensions/nocobase-ai/demo/container-showcase.tsx +210 -0
- package/src/extensions/nocobase-ai/demo/floating.tsx +134 -0
- package/src/extensions/nocobase-ai/demo/index.tsx +346 -0
- package/src/extensions/nocobase-ai/demo/interaction-showcase.tsx +151 -0
- package/src/extensions/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
- package/src/extensions/nocobase-ai/demo/page-context.tsx +884 -0
- package/src/extensions/nocobase-ai/demo/page-element-showcase.tsx +197 -0
- package/src/extensions/nocobase-ai/demo/prompt-card.tsx +41 -0
- package/src/extensions/nocobase-ai/demo/prompt-generator.tsx +452 -0
- package/src/extensions/nocobase-ai/demo/shortcut.tsx +1207 -0
- package/src/extensions/nocobase-ai/demo/tool-cards.tsx +530 -0
- package/src/extensions/nocobase-ai/extension.tsx +158 -0
- package/src/extensions/nocobase-ai/global-ai-chat.tsx +213 -0
- package/src/extensions/nocobase-ai/index.ts +3 -0
- package/src/extensions/nocobase-ai/providers/ai-provider.tsx +323 -0
- package/src/extensions/nocobase-ai/providers/avatars.ts +817 -0
- package/src/extensions/nocobase-ai/providers/chat-context.tsx +109 -0
- package/src/extensions/nocobase-ai/providers/chat-controller.ts +84 -0
- package/src/extensions/nocobase-ai/providers/chat-message-utils.ts +198 -0
- package/src/extensions/nocobase-ai/providers/chat-provider.tsx +871 -0
- package/src/extensions/nocobase-ai/providers/chat-reducer.ts +147 -0
- package/src/extensions/nocobase-ai/providers/chat-task-utils.ts +105 -0
- package/src/extensions/nocobase-ai/providers/chat-transport.ts +184 -0
- package/src/extensions/nocobase-ai/providers/form-registry.tsx +271 -0
- package/src/extensions/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
- package/src/extensions/nocobase-ai/providers/index.ts +61 -0
- package/src/extensions/nocobase-ai/providers/model.ts +32 -0
- package/src/extensions/nocobase-ai/providers/page-context.tsx +136 -0
- package/src/extensions/nocobase-ai/providers/stream-coalescer.ts +60 -0
- package/src/extensions/nocobase-ai/providers/stream-event-utils.ts +82 -0
- package/src/extensions/nocobase-ai/providers/stream-parser.ts +61 -0
- package/src/extensions/nocobase-ai/providers/sub-agent-stream.ts +316 -0
- package/src/extensions/nocobase-ai/providers/types.ts +183 -0
- package/src/extensions/nocobase-ai/providers/ui-message-stream.ts +463 -0
- package/src/extensions/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
- package/src/extensions/nocobase-ai/providers/use-chat-attachments.ts +206 -0
- package/src/extensions/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
- package/src/extensions/nocobase-ai/providers/use-chat-runtime.ts +210 -0
- package/src/extensions/nocobase-ai/providers/use-chat-work-context.ts +93 -0
- package/src/extensions/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
- package/src/extensions/nocobase-ai/providers/use-conversation-history.ts +178 -0
- package/src/extensions/nocobase-ai/services/index.ts +7 -0
- package/src/extensions/nocobase-ai/services/nocobase-ai-service.ts +454 -0
- 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
|
+
}
|