@modern-admin/react 0.1.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/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- package/src/validation.ts +361 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Browser-side ComponentLoader. Custom property/action components register
|
|
2
|
+
// themselves by name and consumers look them up by string. We intentionally
|
|
3
|
+
// keep this synchronous (no rollup/runtime bundling) — apps import their
|
|
4
|
+
// custom components as ES modules and call `.add()` at startup.
|
|
5
|
+
|
|
6
|
+
import type * as React from 'react'
|
|
7
|
+
|
|
8
|
+
// We can't enforce a single prop shape because each registered slot accepts
|
|
9
|
+
// different props (display vs editor). The renderer wraps the lookup with a
|
|
10
|
+
// concrete prop type, so the loader stays untyped at the entry boundary.
|
|
11
|
+
|
|
12
|
+
export type ComponentEntry = React.ComponentType<any>
|
|
13
|
+
|
|
14
|
+
export class ComponentLoader {
|
|
15
|
+
private readonly entries = new Map<string, ComponentEntry>()
|
|
16
|
+
|
|
17
|
+
add(name: string, component: ComponentEntry): this {
|
|
18
|
+
this.entries.set(name, component)
|
|
19
|
+
return this
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
has(name: string): boolean {
|
|
23
|
+
return this.entries.has(name)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get(name: string): ComponentEntry | undefined {
|
|
27
|
+
return this.entries.get(name)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
list(): string[] {
|
|
31
|
+
return Array.from(this.entries.keys())
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuLabel,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
RichtextRender,
|
|
15
|
+
ScrollArea,
|
|
16
|
+
Sheet,
|
|
17
|
+
SheetClose,
|
|
18
|
+
SheetContent,
|
|
19
|
+
SheetDescription,
|
|
20
|
+
SheetHeader,
|
|
21
|
+
SheetTitle,
|
|
22
|
+
Textarea,
|
|
23
|
+
} from '@modern-admin/ui'
|
|
24
|
+
import { Bot, History, Loader2, MessageSquare, Plus, Send, Settings, X } from 'lucide-react'
|
|
25
|
+
import { uuidv7 } from '@modern-admin/core'
|
|
26
|
+
import { useAdminClient } from '../provider.js'
|
|
27
|
+
import { useFeatures } from '../hooks.js'
|
|
28
|
+
import { useNotify } from '../notify.js'
|
|
29
|
+
import { useBasepath, useNavigate } from '../router.js'
|
|
30
|
+
import { useI18n } from '../i18n.js'
|
|
31
|
+
import type {
|
|
32
|
+
AiAssistantChatHistoryItem,
|
|
33
|
+
AiAssistantChatMessage,
|
|
34
|
+
AiAssistantCitation,
|
|
35
|
+
AiAssistantTask,
|
|
36
|
+
AiUiAction,
|
|
37
|
+
} from '../client.js'
|
|
38
|
+
import { emitDashboardReload } from '../use-dashboard-charts.js'
|
|
39
|
+
|
|
40
|
+
interface ChatItem {
|
|
41
|
+
id: string
|
|
42
|
+
role: 'user' | 'assistant'
|
|
43
|
+
content: string
|
|
44
|
+
citations?: AiAssistantCitation[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface QueuedItem {
|
|
48
|
+
id: string
|
|
49
|
+
content: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const randomId = (): string => uuidv7()
|
|
53
|
+
|
|
54
|
+
const MAX_QUEUE = 5
|
|
55
|
+
|
|
56
|
+
const messagesFromTask = (task: AiAssistantTask, fallbackText: string): ChatItem[] => {
|
|
57
|
+
const inputMessages = Array.isArray(task.input?.messages)
|
|
58
|
+
? task.input.messages as AiAssistantChatMessage[]
|
|
59
|
+
: []
|
|
60
|
+
const items: ChatItem[] = inputMessages.map((message, index) => ({
|
|
61
|
+
id: `${task.id}-input-${index}`,
|
|
62
|
+
role: message.role,
|
|
63
|
+
content: message.content,
|
|
64
|
+
}))
|
|
65
|
+
if (task.output) {
|
|
66
|
+
const text = String(task.output.text ?? '').trim()
|
|
67
|
+
items.push({
|
|
68
|
+
id: `task-${task.id}`,
|
|
69
|
+
role: 'assistant',
|
|
70
|
+
content: text || fallbackText,
|
|
71
|
+
citations: Array.isArray(task.output.citations) ? task.output.citations : undefined,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
return items
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function AiAssistantWidget(): React.ReactElement | null {
|
|
78
|
+
const client = useAdminClient()
|
|
79
|
+
const queryClient = useQueryClient()
|
|
80
|
+
const notify = useNotify()
|
|
81
|
+
const navigate = useNavigate()
|
|
82
|
+
const basepath = useBasepath()
|
|
83
|
+
const { locale, t } = useI18n()
|
|
84
|
+
const [open, setOpen] = React.useState(false)
|
|
85
|
+
const [input, setInput] = React.useState('')
|
|
86
|
+
const [messages, setMessages] = React.useState<ChatItem[]>([])
|
|
87
|
+
const [conversationId, setConversationId] = React.useState(() => randomId())
|
|
88
|
+
const [activeTaskId, setActiveTaskId] = React.useState<string | null>(null)
|
|
89
|
+
const [queue, setQueue] = React.useState<QueuedItem[]>([])
|
|
90
|
+
|
|
91
|
+
// Capability gate: when the host hasn't wired `aiAssistant` in
|
|
92
|
+
// `ModernAdminModule.forRoot`, the `/admin/api/ai-assistant/*`
|
|
93
|
+
// controllers aren't even registered — issuing the settings query would
|
|
94
|
+
// 404 every page load. Skip the query, the widget, and the chat sheet
|
|
95
|
+
// entirely in that case.
|
|
96
|
+
const features = useFeatures()
|
|
97
|
+
const aiAvailable = features.aiAssistant
|
|
98
|
+
|
|
99
|
+
const settings = useQuery({
|
|
100
|
+
queryKey: ['modern-admin', 'ai-assistant', 'settings'],
|
|
101
|
+
queryFn: () => client.getAiAssistantSettings(),
|
|
102
|
+
enabled: aiAvailable,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const chat = useMutation({
|
|
106
|
+
mutationFn: async (input: {
|
|
107
|
+
requestId: string
|
|
108
|
+
conversationId: string
|
|
109
|
+
messages: AiAssistantChatMessage[]
|
|
110
|
+
}) => {
|
|
111
|
+
// Strip the admin's basepath so the backend receives a basepath-relative
|
|
112
|
+
// path (e.g. `/resources/posts/123`, not `/admin/resources/posts/123`) —
|
|
113
|
+
// the grounding prompt matches against the relative `/resources/...` form.
|
|
114
|
+
// Boundary-aware: only strip a full segment match so basepath `/admin`
|
|
115
|
+
// never mangles an unrelated path like `/administrators`.
|
|
116
|
+
let pathname = typeof window !== 'undefined' ? window.location.pathname : undefined
|
|
117
|
+
if (pathname && basepath) {
|
|
118
|
+
if (pathname === basepath) {
|
|
119
|
+
pathname = '/'
|
|
120
|
+
} else if (pathname.startsWith(`${basepath}/`)) {
|
|
121
|
+
pathname = pathname.slice(basepath.length)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return client.sendAiAssistantChat(
|
|
125
|
+
input.messages,
|
|
126
|
+
input.requestId,
|
|
127
|
+
locale,
|
|
128
|
+
input.conversationId,
|
|
129
|
+
pathname ? { pathname } : undefined,
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
onSuccess: (response) => {
|
|
133
|
+
setActiveTaskId(response.taskId)
|
|
134
|
+
void queryClient.invalidateQueries({ queryKey: ['modern-admin', 'ai-assistant', 'chats'] })
|
|
135
|
+
},
|
|
136
|
+
onError: (err) => {
|
|
137
|
+
notify.error({ message: err instanceof Error ? err.message : String(err) })
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const history = useQuery<AiAssistantChatHistoryItem[]>({
|
|
142
|
+
queryKey: ['modern-admin', 'ai-assistant', 'chats'],
|
|
143
|
+
queryFn: () => client.listAiAssistantChats(),
|
|
144
|
+
enabled: aiAvailable && open && settings.data?.configured === true,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const task = useQuery<AiAssistantTask>({
|
|
148
|
+
queryKey: ['modern-admin', 'ai-assistant', 'task', activeTaskId],
|
|
149
|
+
queryFn: () => client.getAiAssistantTask(activeTaskId!),
|
|
150
|
+
enabled: !!activeTaskId,
|
|
151
|
+
refetchInterval: (query) => {
|
|
152
|
+
const state = query.state.data?.status
|
|
153
|
+
return state === 'pending' || state === 'running' ? 1200 : false
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
React.useEffect(() => {
|
|
158
|
+
const data = task.data
|
|
159
|
+
if (!data || !activeTaskId) return
|
|
160
|
+
if (data.status === 'succeeded' && data.output != null) {
|
|
161
|
+
setMessages((prev) => {
|
|
162
|
+
const alreadyExists = prev.some((item) => item.id === `task-${data.id}`)
|
|
163
|
+
if (alreadyExists) return prev
|
|
164
|
+
const text = String(data.output?.text ?? '').trim()
|
|
165
|
+
return [
|
|
166
|
+
...prev,
|
|
167
|
+
{
|
|
168
|
+
id: `task-${data.id}`,
|
|
169
|
+
role: 'assistant',
|
|
170
|
+
content: text || t('aiAssistant:noText'),
|
|
171
|
+
citations: Array.isArray(data.output?.citations) ? data.output.citations : undefined,
|
|
172
|
+
},
|
|
173
|
+
]
|
|
174
|
+
})
|
|
175
|
+
setActiveTaskId(null)
|
|
176
|
+
void queryClient.invalidateQueries({ queryKey: ['modern-admin', 'ai-assistant', 'chats'] })
|
|
177
|
+
const uiActions = Array.isArray(data.output?.uiActions)
|
|
178
|
+
? (data.output.uiActions as AiUiAction[])
|
|
179
|
+
: []
|
|
180
|
+
for (const action of uiActions) {
|
|
181
|
+
if (action.kind === 'refresh' && action.target === 'dashboard') {
|
|
182
|
+
emitDashboardReload()
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (action.kind === 'navigate') {
|
|
186
|
+
setOpen(false)
|
|
187
|
+
navigate(action.route)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (data.status === 'failed' || data.status === 'cancelled') {
|
|
193
|
+
notify.error({ message: data.error ?? t('aiAssistant:taskFailed') })
|
|
194
|
+
setActiveTaskId(null)
|
|
195
|
+
}
|
|
196
|
+
}, [activeTaskId, navigate, notify, queryClient, task.data, t])
|
|
197
|
+
|
|
198
|
+
// Send a user message immediately (assumes nothing is in flight).
|
|
199
|
+
const sendChat = React.useCallback(
|
|
200
|
+
(content: string): void => {
|
|
201
|
+
const userMsg: ChatItem = { id: randomId(), role: 'user', content }
|
|
202
|
+
const updated = [...messages, userMsg]
|
|
203
|
+
setMessages(updated)
|
|
204
|
+
chat.mutate({
|
|
205
|
+
requestId: userMsg.id,
|
|
206
|
+
conversationId,
|
|
207
|
+
messages: updated.map((m) => ({ role: m.role, content: m.content })),
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
[chat, conversationId, messages],
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const startNewChat = React.useCallback((): void => {
|
|
214
|
+
setConversationId(randomId())
|
|
215
|
+
setMessages([])
|
|
216
|
+
setInput('')
|
|
217
|
+
setQueue([])
|
|
218
|
+
setActiveTaskId(null)
|
|
219
|
+
}, [])
|
|
220
|
+
|
|
221
|
+
const selectChat = React.useCallback(
|
|
222
|
+
async (item: AiAssistantChatHistoryItem): Promise<void> => {
|
|
223
|
+
try {
|
|
224
|
+
const selected = await client.getAiAssistantTask(item.taskId)
|
|
225
|
+
setConversationId(item.conversationId)
|
|
226
|
+
setMessages(messagesFromTask(selected, t('aiAssistant:noText')))
|
|
227
|
+
setQueue([])
|
|
228
|
+
setInput('')
|
|
229
|
+
setActiveTaskId(
|
|
230
|
+
selected.status === 'pending' || selected.status === 'running' ? selected.id : null,
|
|
231
|
+
)
|
|
232
|
+
} catch (err) {
|
|
233
|
+
notify.error({ message: err instanceof Error ? err.message : String(err) })
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
[client, notify, t],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
// Auto-dequeue: when nothing is in flight and queue has items, send the head.
|
|
240
|
+
React.useEffect(() => {
|
|
241
|
+
if (activeTaskId !== null) return
|
|
242
|
+
if (chat.isPending) return
|
|
243
|
+
const next = queue[0]
|
|
244
|
+
if (!next) return
|
|
245
|
+
setQueue((q) => q.slice(1))
|
|
246
|
+
sendChat(next.content)
|
|
247
|
+
}, [activeTaskId, chat.isPending, queue, sendChat])
|
|
248
|
+
|
|
249
|
+
if (!aiAvailable) return null
|
|
250
|
+
if (settings.isLoading) return null
|
|
251
|
+
if (settings.error) return null
|
|
252
|
+
if (!settings.data?.enabled) return null
|
|
253
|
+
if (!settings.data.canChat) return null
|
|
254
|
+
|
|
255
|
+
const isProcessing = chat.isPending || activeTaskId != null
|
|
256
|
+
const queueFull = queue.length >= MAX_QUEUE
|
|
257
|
+
|
|
258
|
+
const submit = (): void => {
|
|
259
|
+
const text = input.trim()
|
|
260
|
+
if (!text) return
|
|
261
|
+
if (isProcessing) {
|
|
262
|
+
if (queueFull) return
|
|
263
|
+
setQueue((q) => [...q, { id: randomId(), content: text }])
|
|
264
|
+
setInput('')
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
setInput('')
|
|
268
|
+
sendChat(text)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const configured = settings.data.configured
|
|
272
|
+
const isThinking =
|
|
273
|
+
isProcessing ||
|
|
274
|
+
task.data?.status === 'running' ||
|
|
275
|
+
task.data?.status === 'pending'
|
|
276
|
+
const progress = typeof task.data?.progress === 'number' ? task.data.progress : null
|
|
277
|
+
const sendDisabled = input.trim().length === 0 || (isProcessing && queueFull)
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<>
|
|
281
|
+
<div className="fixed bottom-4 right-4 z-40">
|
|
282
|
+
<Button
|
|
283
|
+
onClick={() => setOpen(true)}
|
|
284
|
+
aria-label={t('aiAssistant:fab.label')}
|
|
285
|
+
className={
|
|
286
|
+
'group h-10 min-w-10 max-w-10 rounded-full pl-3 pr-3 shadow-lg ' +
|
|
287
|
+
'justify-end gap-0 overflow-hidden opacity-70 hover:opacity-100 ' +
|
|
288
|
+
'transition-[max-width,padding-left,opacity] duration-200 ease-in-out ' +
|
|
289
|
+
'hover:max-w-36 hover:pl-4'
|
|
290
|
+
}
|
|
291
|
+
>
|
|
292
|
+
<span className="max-w-0 overflow-hidden whitespace-nowrap text-sm font-medium transition-[max-width,padding-right] duration-200 ease-in-out group-hover:max-w-[5rem] group-hover:pr-2">
|
|
293
|
+
{t('aiAssistant:fab.short')}
|
|
294
|
+
</span>
|
|
295
|
+
<MessageSquare className="size-4 shrink-0" />
|
|
296
|
+
</Button>
|
|
297
|
+
</div>
|
|
298
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
299
|
+
<SheetContent hideCloseButton className="flex w-full flex-col gap-0 sm:max-w-xl">
|
|
300
|
+
<SheetHeader className="border-b border-border pb-4">
|
|
301
|
+
<div className="flex items-start gap-3">
|
|
302
|
+
<div className="min-w-0 flex-1">
|
|
303
|
+
<SheetTitle className="flex items-center gap-2">
|
|
304
|
+
<Bot className="size-5" />
|
|
305
|
+
{t('aiAssistant:title')}
|
|
306
|
+
</SheetTitle>
|
|
307
|
+
<SheetDescription>{t('aiAssistant:description')}</SheetDescription>
|
|
308
|
+
</div>
|
|
309
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
310
|
+
{configured && (
|
|
311
|
+
<>
|
|
312
|
+
<DropdownMenu>
|
|
313
|
+
<DropdownMenuTrigger asChild>
|
|
314
|
+
<Button
|
|
315
|
+
type="button"
|
|
316
|
+
variant="ghost"
|
|
317
|
+
size="icon"
|
|
318
|
+
aria-label={t('aiAssistant:history')}
|
|
319
|
+
>
|
|
320
|
+
<History className="size-4" />
|
|
321
|
+
</Button>
|
|
322
|
+
</DropdownMenuTrigger>
|
|
323
|
+
<DropdownMenuContent align="end" className="w-72">
|
|
324
|
+
<DropdownMenuLabel>{t('aiAssistant:history')}</DropdownMenuLabel>
|
|
325
|
+
<DropdownMenuSeparator />
|
|
326
|
+
{history.isLoading && (
|
|
327
|
+
<DropdownMenuItem disabled>{t('common:loading')}</DropdownMenuItem>
|
|
328
|
+
)}
|
|
329
|
+
{!history.isLoading && (history.data?.length ?? 0) === 0 && (
|
|
330
|
+
<DropdownMenuItem disabled>{t('aiAssistant:history.empty')}</DropdownMenuItem>
|
|
331
|
+
)}
|
|
332
|
+
{history.data?.map((item) => (
|
|
333
|
+
<DropdownMenuItem
|
|
334
|
+
key={item.conversationId}
|
|
335
|
+
className="flex-col items-start gap-0 py-2"
|
|
336
|
+
onSelect={() => {
|
|
337
|
+
void selectChat(item)
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
<span className="line-clamp-1 w-full text-sm font-medium">{item.title}</span>
|
|
341
|
+
<span className="text-xs text-muted-foreground">
|
|
342
|
+
{new Intl.DateTimeFormat(locale, {
|
|
343
|
+
dateStyle: 'short',
|
|
344
|
+
timeStyle: 'short',
|
|
345
|
+
}).format(new Date(item.updatedAt))}
|
|
346
|
+
</span>
|
|
347
|
+
</DropdownMenuItem>
|
|
348
|
+
))}
|
|
349
|
+
</DropdownMenuContent>
|
|
350
|
+
</DropdownMenu>
|
|
351
|
+
<Button
|
|
352
|
+
type="button"
|
|
353
|
+
variant="ghost"
|
|
354
|
+
size="icon"
|
|
355
|
+
onClick={startNewChat}
|
|
356
|
+
aria-label={t('aiAssistant:newChat')}
|
|
357
|
+
>
|
|
358
|
+
<Plus className="size-4" />
|
|
359
|
+
</Button>
|
|
360
|
+
</>
|
|
361
|
+
)}
|
|
362
|
+
<SheetClose asChild>
|
|
363
|
+
<Button
|
|
364
|
+
type="button"
|
|
365
|
+
variant="ghost"
|
|
366
|
+
size="icon"
|
|
367
|
+
aria-label={t('common:close')}
|
|
368
|
+
>
|
|
369
|
+
<X className="size-4" />
|
|
370
|
+
</Button>
|
|
371
|
+
</SheetClose>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</SheetHeader>
|
|
375
|
+
|
|
376
|
+
{!configured ? (
|
|
377
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4 text-center">
|
|
378
|
+
<div className="space-y-2">
|
|
379
|
+
<div className="text-lg font-semibold">
|
|
380
|
+
{t('aiAssistant:notConfigured.title')}
|
|
381
|
+
</div>
|
|
382
|
+
<div className="text-sm text-muted-foreground">
|
|
383
|
+
{t('aiAssistant:notConfigured.description')}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
{settings.data.canManage && (
|
|
387
|
+
<Button
|
|
388
|
+
variant="outline"
|
|
389
|
+
onClick={() => {
|
|
390
|
+
setOpen(false)
|
|
391
|
+
navigate({ name: 'settings', section: 'ai-assistant' })
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
<Settings className="size-4" />
|
|
395
|
+
{t('aiAssistant:notConfigured.openSettings')}
|
|
396
|
+
</Button>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
) : (
|
|
400
|
+
<>
|
|
401
|
+
<ScrollArea className="flex-1 px-4 py-4">
|
|
402
|
+
<div className="space-y-3">
|
|
403
|
+
{messages.length === 0 && (
|
|
404
|
+
<Card>
|
|
405
|
+
<CardContent className="space-y-2 p-4 text-sm text-muted-foreground">
|
|
406
|
+
<div className="font-medium text-foreground">
|
|
407
|
+
{t('aiAssistant:tryAsking')}
|
|
408
|
+
</div>
|
|
409
|
+
<div>- {t('aiAssistant:tryAsking.example1')}</div>
|
|
410
|
+
<div>- {t('aiAssistant:tryAsking.example2')}</div>
|
|
411
|
+
<div>- {t('aiAssistant:tryAsking.example3')}</div>
|
|
412
|
+
</CardContent>
|
|
413
|
+
</Card>
|
|
414
|
+
)}
|
|
415
|
+
{messages.map((message) => (
|
|
416
|
+
<div
|
|
417
|
+
key={message.id}
|
|
418
|
+
className={message.role === 'user' ? 'flex justify-end' : 'flex justify-start'}
|
|
419
|
+
>
|
|
420
|
+
<div
|
|
421
|
+
className={message.role === 'user'
|
|
422
|
+
? 'max-w-[85%] rounded-2xl bg-primary px-4 py-3 text-sm text-primary-foreground'
|
|
423
|
+
: 'max-w-[85%] rounded-2xl border bg-card px-4 py-3 text-sm'}
|
|
424
|
+
>
|
|
425
|
+
{message.role === 'assistant' ? (
|
|
426
|
+
<RichtextRender
|
|
427
|
+
value={message.content}
|
|
428
|
+
format="markdown"
|
|
429
|
+
className="text-sm [&_pre]:overflow-x-auto [&_pre]:text-xs"
|
|
430
|
+
/>
|
|
431
|
+
) : (
|
|
432
|
+
<div className="whitespace-pre-wrap">{message.content}</div>
|
|
433
|
+
)}
|
|
434
|
+
{message.citations && message.citations.length > 0 && (
|
|
435
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
436
|
+
{message.citations.slice(0, 6).map((citation, index) => {
|
|
437
|
+
const label = `${citation.resourceId}${citation.recordId ? `#${citation.recordId}` : ''}`
|
|
438
|
+
const key = `${citation.resourceId}-${citation.recordId ?? 'resource'}-${index}`
|
|
439
|
+
if (citation.recordId) {
|
|
440
|
+
return (
|
|
441
|
+
<button
|
|
442
|
+
key={key}
|
|
443
|
+
type="button"
|
|
444
|
+
className="cursor-pointer"
|
|
445
|
+
onClick={() => {
|
|
446
|
+
setOpen(false)
|
|
447
|
+
navigate({
|
|
448
|
+
name: 'show',
|
|
449
|
+
resourceId: citation.resourceId,
|
|
450
|
+
recordId: citation.recordId!,
|
|
451
|
+
})
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
<Badge variant="outline">{label}</Badge>
|
|
455
|
+
</button>
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
return (
|
|
459
|
+
<button
|
|
460
|
+
key={key}
|
|
461
|
+
type="button"
|
|
462
|
+
className="cursor-pointer"
|
|
463
|
+
onClick={() => {
|
|
464
|
+
setOpen(false)
|
|
465
|
+
navigate({ name: 'list', resourceId: citation.resourceId })
|
|
466
|
+
}}
|
|
467
|
+
>
|
|
468
|
+
<Badge variant="outline">{label}</Badge>
|
|
469
|
+
</button>
|
|
470
|
+
)
|
|
471
|
+
})}
|
|
472
|
+
</div>
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
))}
|
|
477
|
+
{isThinking && (
|
|
478
|
+
<div className="flex justify-start">
|
|
479
|
+
<div className="inline-flex items-center gap-2 rounded-2xl border bg-card px-4 py-3 text-sm text-muted-foreground">
|
|
480
|
+
<Loader2 className="size-4 animate-spin" />
|
|
481
|
+
{progress != null
|
|
482
|
+
? t('aiAssistant:thinkingProgress', { progress })
|
|
483
|
+
: t('aiAssistant:thinking')}
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
487
|
+
</div>
|
|
488
|
+
</ScrollArea>
|
|
489
|
+
|
|
490
|
+
<div className="border-t border-border bg-background/95 px-3 py-2">
|
|
491
|
+
{queue.length > 0 && (
|
|
492
|
+
<div className="mb-2 space-y-1">
|
|
493
|
+
{queue.map((item, index) => (
|
|
494
|
+
<div
|
|
495
|
+
key={item.id}
|
|
496
|
+
className="flex items-center gap-2 rounded-md bg-muted/50 px-2 py-1 text-sm"
|
|
497
|
+
>
|
|
498
|
+
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
|
499
|
+
({index + 1})
|
|
500
|
+
</span>
|
|
501
|
+
<span className="flex-1 truncate" title={item.content}>
|
|
502
|
+
{item.content}
|
|
503
|
+
</span>
|
|
504
|
+
<Button
|
|
505
|
+
type="button"
|
|
506
|
+
variant="ghost"
|
|
507
|
+
size="icon"
|
|
508
|
+
className="size-6 shrink-0"
|
|
509
|
+
onClick={() =>
|
|
510
|
+
setQueue((q) => q.filter((entry) => entry.id !== item.id))
|
|
511
|
+
}
|
|
512
|
+
aria-label={t('aiAssistant:queue.cancel')}
|
|
513
|
+
>
|
|
514
|
+
<X className="size-3.5" />
|
|
515
|
+
</Button>
|
|
516
|
+
</div>
|
|
517
|
+
))}
|
|
518
|
+
</div>
|
|
519
|
+
)}
|
|
520
|
+
<form
|
|
521
|
+
onSubmit={(e) => {
|
|
522
|
+
e.preventDefault()
|
|
523
|
+
submit()
|
|
524
|
+
}}
|
|
525
|
+
>
|
|
526
|
+
<div className="flex items-center gap-1">
|
|
527
|
+
<Textarea
|
|
528
|
+
value={input}
|
|
529
|
+
onChange={(e) => setInput(e.target.value)}
|
|
530
|
+
onKeyDown={(e) => {
|
|
531
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
532
|
+
e.preventDefault()
|
|
533
|
+
submit()
|
|
534
|
+
}
|
|
535
|
+
}}
|
|
536
|
+
rows={1}
|
|
537
|
+
placeholder={t('aiAssistant:input.placeholder')}
|
|
538
|
+
aria-label={t('aiAssistant:input.placeholder')}
|
|
539
|
+
className={
|
|
540
|
+
'flex-1 max-h-[10rem] min-h-0 resize-none border-0 bg-transparent px-1 py-2 ' +
|
|
541
|
+
'text-sm shadow-none outline-none [field-sizing:content] ' +
|
|
542
|
+
'focus-visible:ring-0 focus-visible:ring-offset-0'
|
|
543
|
+
}
|
|
544
|
+
/>
|
|
545
|
+
<Button
|
|
546
|
+
type="submit"
|
|
547
|
+
disabled={sendDisabled}
|
|
548
|
+
aria-label={t('aiAssistant:send')}
|
|
549
|
+
className="size-8 shrink-0 rounded-full p-0"
|
|
550
|
+
>
|
|
551
|
+
<Send className="size-4" />
|
|
552
|
+
</Button>
|
|
553
|
+
</div>
|
|
554
|
+
<p className="px-1 pb-1 text-xs text-muted-foreground">
|
|
555
|
+
{t('aiAssistant:input.hint')}
|
|
556
|
+
</p>
|
|
557
|
+
</form>
|
|
558
|
+
</div>
|
|
559
|
+
</>
|
|
560
|
+
)}
|
|
561
|
+
</SheetContent>
|
|
562
|
+
</Sheet>
|
|
563
|
+
</>
|
|
564
|
+
)
|
|
565
|
+
}
|