@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.
Files changed (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. 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
+ }