@nocobase/portal-template-default 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/package.json +1 -1
  2. package/scripts/registry.mjs +2 -0
  3. package/src/extensions/nocobase-acl/README.md +19 -0
  4. package/src/extensions/nocobase-acl/components/acl-boundary.tsx +106 -0
  5. package/src/extensions/nocobase-acl/components/index.ts +3 -0
  6. package/src/extensions/nocobase-acl/components/role-options.ts +36 -0
  7. package/src/extensions/nocobase-acl/components/role-switcher.tsx +135 -0
  8. package/src/extensions/nocobase-acl/demo/boundary-api.tsx +186 -0
  9. package/src/extensions/nocobase-acl/demo/components.tsx +231 -0
  10. package/src/extensions/nocobase-acl/demo/index.tsx +505 -0
  11. package/src/extensions/nocobase-acl/demo/lazy-route.tsx +10 -0
  12. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +134 -0
  13. package/src/extensions/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
  14. package/src/extensions/nocobase-acl/demo/scenario-section.tsx +37 -0
  15. package/src/extensions/nocobase-acl/extension.tsx +71 -0
  16. package/src/extensions/nocobase-acl/index.ts +1 -0
  17. package/src/extensions/nocobase-ai/README.md +70 -0
  18. package/src/extensions/nocobase-ai/adapters/react-hook-form.ts +20 -0
  19. package/src/extensions/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
  20. package/src/extensions/nocobase-ai/components/ai-root-provider.tsx +32 -0
  21. package/src/extensions/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
  22. package/src/extensions/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
  23. package/src/extensions/nocobase-ai/components/chat/chat-compact.tsx +90 -0
  24. package/src/extensions/nocobase-ai/components/chat/chat-composer.tsx +429 -0
  25. package/src/extensions/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
  26. package/src/extensions/nocobase-ai/components/chat/chat-header.tsx +88 -0
  27. package/src/extensions/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
  28. package/src/extensions/nocobase-ai/components/chat/chat-message.tsx +252 -0
  29. package/src/extensions/nocobase-ai/components/chat/chat-messages.tsx +173 -0
  30. package/src/extensions/nocobase-ai/components/chat/chat-window.tsx +118 -0
  31. package/src/extensions/nocobase-ai/components/chat/conversation-list.tsx +343 -0
  32. package/src/extensions/nocobase-ai/components/chat/markdown-message.tsx +165 -0
  33. package/src/extensions/nocobase-ai/components/chat/model-select-options.tsx +30 -0
  34. package/src/extensions/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
  35. package/src/extensions/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
  36. package/src/extensions/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
  37. package/src/extensions/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
  38. package/src/extensions/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
  39. package/src/extensions/nocobase-ai/components/index.ts +59 -0
  40. package/src/extensions/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
  41. package/src/extensions/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
  42. package/src/extensions/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
  43. package/src/extensions/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
  44. package/src/extensions/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
  45. package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
  46. package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
  47. package/src/extensions/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
  48. package/src/extensions/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
  49. package/src/extensions/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
  50. package/src/extensions/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
  51. package/src/extensions/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
  52. package/src/extensions/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
  53. package/src/extensions/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
  54. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
  55. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
  56. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
  57. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
  58. package/src/extensions/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
  59. package/src/extensions/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
  60. package/src/extensions/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
  61. package/src/extensions/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
  62. package/src/extensions/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
  63. package/src/extensions/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
  64. package/src/extensions/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
  65. package/src/extensions/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
  66. package/src/extensions/nocobase-ai/demo/configuration-gate.tsx +49 -0
  67. package/src/extensions/nocobase-ai/demo/container-showcase.tsx +210 -0
  68. package/src/extensions/nocobase-ai/demo/floating.tsx +134 -0
  69. package/src/extensions/nocobase-ai/demo/index.tsx +346 -0
  70. package/src/extensions/nocobase-ai/demo/interaction-showcase.tsx +151 -0
  71. package/src/extensions/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
  72. package/src/extensions/nocobase-ai/demo/page-context.tsx +884 -0
  73. package/src/extensions/nocobase-ai/demo/page-element-showcase.tsx +197 -0
  74. package/src/extensions/nocobase-ai/demo/prompt-card.tsx +41 -0
  75. package/src/extensions/nocobase-ai/demo/prompt-generator.tsx +452 -0
  76. package/src/extensions/nocobase-ai/demo/shortcut.tsx +1207 -0
  77. package/src/extensions/nocobase-ai/demo/tool-cards.tsx +530 -0
  78. package/src/extensions/nocobase-ai/extension.tsx +158 -0
  79. package/src/extensions/nocobase-ai/global-ai-chat.tsx +213 -0
  80. package/src/extensions/nocobase-ai/index.ts +3 -0
  81. package/src/extensions/nocobase-ai/providers/ai-provider.tsx +323 -0
  82. package/src/extensions/nocobase-ai/providers/avatars.ts +817 -0
  83. package/src/extensions/nocobase-ai/providers/chat-context.tsx +109 -0
  84. package/src/extensions/nocobase-ai/providers/chat-controller.ts +84 -0
  85. package/src/extensions/nocobase-ai/providers/chat-message-utils.ts +198 -0
  86. package/src/extensions/nocobase-ai/providers/chat-provider.tsx +871 -0
  87. package/src/extensions/nocobase-ai/providers/chat-reducer.ts +147 -0
  88. package/src/extensions/nocobase-ai/providers/chat-task-utils.ts +105 -0
  89. package/src/extensions/nocobase-ai/providers/chat-transport.ts +184 -0
  90. package/src/extensions/nocobase-ai/providers/form-registry.tsx +271 -0
  91. package/src/extensions/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
  92. package/src/extensions/nocobase-ai/providers/index.ts +61 -0
  93. package/src/extensions/nocobase-ai/providers/model.ts +32 -0
  94. package/src/extensions/nocobase-ai/providers/page-context.tsx +136 -0
  95. package/src/extensions/nocobase-ai/providers/stream-coalescer.ts +60 -0
  96. package/src/extensions/nocobase-ai/providers/stream-event-utils.ts +82 -0
  97. package/src/extensions/nocobase-ai/providers/stream-parser.ts +61 -0
  98. package/src/extensions/nocobase-ai/providers/sub-agent-stream.ts +316 -0
  99. package/src/extensions/nocobase-ai/providers/types.ts +183 -0
  100. package/src/extensions/nocobase-ai/providers/ui-message-stream.ts +463 -0
  101. package/src/extensions/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
  102. package/src/extensions/nocobase-ai/providers/use-chat-attachments.ts +206 -0
  103. package/src/extensions/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
  104. package/src/extensions/nocobase-ai/providers/use-chat-runtime.ts +210 -0
  105. package/src/extensions/nocobase-ai/providers/use-chat-work-context.ts +93 -0
  106. package/src/extensions/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
  107. package/src/extensions/nocobase-ai/providers/use-conversation-history.ts +178 -0
  108. package/src/extensions/nocobase-ai/services/index.ts +7 -0
  109. package/src/extensions/nocobase-ai/services/nocobase-ai-service.ts +454 -0
  110. package/src/extensions/nocobase-ai/services/types.ts +82 -0
@@ -0,0 +1,90 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import { cn } from "@/lib/utils";
3
+ import { History, LoaderCircle } from "lucide-react";
4
+ import { useState, type ReactNode } from "react";
5
+ import {
6
+ useAIChatBase,
7
+ useAIChatStatus,
8
+ type AIToolCallDecision,
9
+ } from "../../providers";
10
+ import { ChatComposer, type AIChatComposerAction } from "./chat-composer";
11
+ import { AIChatHistoryDialog } from "./chat-history-dialog";
12
+ import { ChatHeader } from "./chat-header";
13
+
14
+ export type AIChatCompactProps = {
15
+ className?: string;
16
+ headerActions?: ReactNode;
17
+ composerActions?: AIChatComposerAction[];
18
+ showEmployeeSelector?: boolean;
19
+ showModelSelector?: boolean;
20
+ showUserPrompt?: boolean;
21
+ enableAttachments?: boolean;
22
+ placeholder?: string;
23
+ disclaimer?: ReactNode | false;
24
+ onToolCallDecision?: (decision: AIToolCallDecision) => void | Promise<void>;
25
+ };
26
+
27
+ export function AIChatCompact({
28
+ className,
29
+ headerActions,
30
+ composerActions,
31
+ showEmployeeSelector = true,
32
+ showModelSelector = true,
33
+ showUserPrompt = true,
34
+ enableAttachments = false,
35
+ placeholder,
36
+ disclaimer,
37
+ onToolCallDecision,
38
+ }: AIChatCompactProps) {
39
+ const { currentEmployee } = useAIChatBase();
40
+ const { status } = useAIChatStatus();
41
+ const [historyOpen, setHistoryOpen] = useState(false);
42
+ const busy = status === "submitted" || status === "streaming";
43
+
44
+ return (
45
+ <section
46
+ className={cn(
47
+ "flex min-h-0 w-full flex-col overflow-hidden rounded-xl border bg-card",
48
+ className
49
+ )}
50
+ >
51
+ <ChatHeader
52
+ showConversationToggle={false}
53
+ showUserPrompt={showUserPrompt}
54
+ actions={
55
+ <>
56
+ <Button
57
+ variant="ghost"
58
+ size="icon-sm"
59
+ aria-label="Open message history"
60
+ onClick={() => setHistoryOpen(true)}
61
+ >
62
+ <History />
63
+ </Button>
64
+ {headerActions}
65
+ </>
66
+ }
67
+ />
68
+ {busy ? (
69
+ <div className="flex items-center gap-2 border-b bg-muted/25 px-4 py-2 text-xs text-muted-foreground">
70
+ <LoaderCircle className="size-3.5 animate-spin" />
71
+ {currentEmployee.nickname} is working…
72
+ </div>
73
+ ) : null}
74
+ <ChatComposer
75
+ actions={composerActions}
76
+ showEmployeeSelector={showEmployeeSelector}
77
+ showModelSelector={showModelSelector}
78
+ enableAttachments={enableAttachments}
79
+ placeholder={placeholder}
80
+ disclaimer={disclaimer}
81
+ />
82
+ <AIChatHistoryDialog
83
+ open={historyOpen}
84
+ onOpenChange={setHistoryOpen}
85
+ trigger={null}
86
+ onToolCallDecision={onToolCallDecision}
87
+ />
88
+ </section>
89
+ );
90
+ }
@@ -0,0 +1,429 @@
1
+ import {
2
+ InputGroup,
3
+ InputGroupAddon,
4
+ InputGroupButton,
5
+ InputGroupTextarea,
6
+ } from "@/components/ui/input-group";
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select";
14
+ import {
15
+ HoverCard,
16
+ HoverCardContent,
17
+ HoverCardTrigger,
18
+ } from "@/components/ui/hover-card";
19
+ import {
20
+ getAIModelKey,
21
+ useAIChatBase,
22
+ useAIChatStatus,
23
+ type AIEmployee,
24
+ } from "../../providers";
25
+ import { cn } from "@/lib/utils";
26
+ import { ArrowUp, Paperclip, Pencil, Square, X } from "lucide-react";
27
+ import { useEffect, useRef, useState, type ReactNode } from "react";
28
+ import { AIEmployeeAvatar } from "./ai-employee-avatar";
29
+ import { WorkContextChip } from "./work-context-chip";
30
+ import {
31
+ Tooltip,
32
+ TooltipContent,
33
+ TooltipTrigger,
34
+ } from "@/components/ui/tooltip";
35
+ import { AIModelSelectOptions } from "./model-select-options";
36
+ import { ChatAttachment } from "./chat-attachment";
37
+
38
+ export type AIChatComposerAction = {
39
+ key: string;
40
+ label: string;
41
+ icon?: ReactNode;
42
+ active?: boolean;
43
+ disabled?: boolean;
44
+ showLabel?: boolean;
45
+ onClick?: () => void;
46
+ };
47
+
48
+ export function ChatComposer({
49
+ actions = [],
50
+ showEmployeeSelector = true,
51
+ showModelSelector = true,
52
+ enableAttachments = false,
53
+ attachmentActionIndex = 0,
54
+ placeholder = "Message your AI employee…",
55
+ disclaimer = "AI can make mistakes. Review important changes before publishing.",
56
+ }: {
57
+ actions?: AIChatComposerAction[];
58
+ showEmployeeSelector?: boolean;
59
+ showModelSelector?: boolean;
60
+ enableAttachments?: boolean;
61
+ attachmentActionIndex?: number;
62
+ placeholder?: string;
63
+ disclaimer?: ReactNode | false;
64
+ }) {
65
+ const {
66
+ draft,
67
+ setDraft,
68
+ send,
69
+ stop,
70
+ models,
71
+ employees,
72
+ currentEmployee,
73
+ currentModel,
74
+ selectModel,
75
+ selectEmployee,
76
+ composerFocusRequest,
77
+ attachments,
78
+ uploadingAttachments,
79
+ uploadFiles,
80
+ removeAttachment,
81
+ workContext,
82
+ removeWorkContext,
83
+ editingMessageId,
84
+ cancelEditingMessage,
85
+ } = useAIChatBase();
86
+ const { status } = useAIChatStatus();
87
+ const busy = status === "submitted" || status === "streaming";
88
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
89
+ const fileInputRef = useRef<HTMLInputElement>(null);
90
+ const [employeeSelectOpen, setEmployeeSelectOpen] = useState(false);
91
+ const [previewedEmployee, setPreviewedEmployee] = useState<string>();
92
+ const visibleEmployees = employees.filter(
93
+ (employee) =>
94
+ (employee.category === undefined || employee.category === "business") &&
95
+ employee.deprecated !== true
96
+ );
97
+ const normalizedAttachmentActionIndex = Math.max(
98
+ 0,
99
+ Math.min(attachmentActionIndex, actions.length)
100
+ );
101
+ const renderAction = (action: AIChatComposerAction) => (
102
+ <Tooltip key={action.key}>
103
+ <TooltipTrigger
104
+ render={
105
+ <InputGroupButton
106
+ size={action.showLabel ? "xs" : "icon-sm"}
107
+ variant={action.active ? "secondary" : "ghost"}
108
+ className={cn(
109
+ "h-7 shrink-0",
110
+ action.showLabel ? "px-2" : "w-7 p-0"
111
+ )}
112
+ disabled={action.disabled}
113
+ aria-label={action.label}
114
+ aria-pressed={action.active}
115
+ onClick={action.onClick}
116
+ />
117
+ }
118
+ >
119
+ {action.icon}
120
+ {action.showLabel ? action.label : null}
121
+ </TooltipTrigger>
122
+ <TooltipContent>{action.label}</TooltipContent>
123
+ </Tooltip>
124
+ );
125
+
126
+ useEffect(() => {
127
+ if (composerFocusRequest === 0) return;
128
+ const frame = requestAnimationFrame(() => {
129
+ const textarea = textareaRef.current;
130
+ if (!textarea) return;
131
+ textarea.focus();
132
+ const end = textarea.value.length;
133
+ textarea.setSelectionRange(end, end);
134
+ });
135
+ return () => cancelAnimationFrame(frame);
136
+ }, [composerFocusRequest]);
137
+
138
+ return (
139
+ <footer className="shrink-0 bg-card">
140
+ <div className="mx-4 mt-2">
141
+ <InputGroup className="rounded-xl bg-background shadow-[0_6px_24px_rgba(0,0,0,0.06)] has-disabled:bg-background has-disabled:opacity-100 focus-within:shadow-[0_8px_28px_rgba(0,0,0,0.08)] dark:has-disabled:bg-background">
142
+ {editingMessageId ? (
143
+ <InputGroupAddon
144
+ align="block-start"
145
+ className="justify-between border-b px-3 py-2 text-xs"
146
+ >
147
+ <span className="flex items-center gap-2 text-foreground">
148
+ <Pencil className="size-3.5" /> Editing message
149
+ </span>
150
+ <InputGroupButton
151
+ size="icon-xs"
152
+ aria-label="Cancel editing"
153
+ onClick={cancelEditingMessage}
154
+ >
155
+ <X />
156
+ </InputGroupButton>
157
+ </InputGroupAddon>
158
+ ) : null}
159
+ {workContext.length ? (
160
+ <InputGroupAddon
161
+ align="block-start"
162
+ className="flex-wrap justify-start gap-1.5 px-3 pt-2"
163
+ >
164
+ {workContext.map((item, index) => (
165
+ <WorkContextChip
166
+ key={`${item.type}:${item.id ?? item.title ?? index}`}
167
+ item={item}
168
+ onRemove={() => removeWorkContext(item)}
169
+ />
170
+ ))}
171
+ </InputGroupAddon>
172
+ ) : null}
173
+ {enableAttachments && attachments.length ? (
174
+ <InputGroupAddon
175
+ align="block-start"
176
+ className="flex-wrap justify-start gap-1.5 px-3 pt-2"
177
+ >
178
+ {attachments.map((attachment) => (
179
+ <ChatAttachment
180
+ key={attachment.uid}
181
+ attachment={attachment}
182
+ removable
183
+ onRemove={() => removeAttachment(attachment.uid)}
184
+ />
185
+ ))}
186
+ </InputGroupAddon>
187
+ ) : null}
188
+ <InputGroupTextarea
189
+ ref={textareaRef}
190
+ value={draft}
191
+ rows={2}
192
+ placeholder={placeholder}
193
+ className="max-h-44 min-h-16 px-3 pt-3 text-sm"
194
+ onChange={(event) => setDraft(event.target.value)}
195
+ onPaste={(event) => {
196
+ if (!enableAttachments) return;
197
+ const files = Array.from(event.clipboardData.items)
198
+ .filter((item) => item.kind === "file")
199
+ .map((item) => item.getAsFile())
200
+ .filter((file): file is File => file !== null);
201
+ if (!files.length) return;
202
+ event.preventDefault();
203
+ void uploadFiles(files);
204
+ }}
205
+ onKeyDown={(event) => {
206
+ if (event.nativeEvent.isComposing) return;
207
+ if (event.key === "Enter" && !event.shiftKey) {
208
+ event.preventDefault();
209
+ void send();
210
+ }
211
+ }}
212
+ />
213
+ <InputGroupAddon
214
+ align="block-end"
215
+ className="justify-between px-2.5 pb-2"
216
+ >
217
+ <div className="flex min-w-0 items-center gap-2 overflow-x-auto [scrollbar-width:none]">
218
+ {actions
219
+ .slice(0, normalizedAttachmentActionIndex)
220
+ .map(renderAction)}
221
+ {enableAttachments ? (
222
+ <Tooltip>
223
+ <TooltipTrigger
224
+ render={
225
+ <InputGroupButton
226
+ size="icon-sm"
227
+ variant="ghost"
228
+ className="h-7 w-7 shrink-0 p-0"
229
+ aria-label="Upload files"
230
+ onClick={() => fileInputRef.current?.click()}
231
+ />
232
+ }
233
+ >
234
+ <Paperclip />
235
+ </TooltipTrigger>
236
+ <TooltipContent>Upload files</TooltipContent>
237
+ </Tooltip>
238
+ ) : null}
239
+ {actions.slice(normalizedAttachmentActionIndex).map(renderAction)}
240
+ {showEmployeeSelector ? (
241
+ <Select
242
+ open={employeeSelectOpen}
243
+ value={currentEmployee.username}
244
+ onOpenChange={(open) => {
245
+ setEmployeeSelectOpen(open);
246
+ if (!open) setPreviewedEmployee(undefined);
247
+ }}
248
+ onValueChange={(value) => {
249
+ setPreviewedEmployee(undefined);
250
+ if (value) selectEmployee(value);
251
+ }}
252
+ >
253
+ <SelectTrigger
254
+ size="sm"
255
+ className="max-w-36 shrink-0 border-0 bg-muted/60 px-1.5 shadow-none"
256
+ >
257
+ <SelectValue>
258
+ <span className="flex min-w-0 items-center gap-1.5">
259
+ <AIEmployeeAvatar
260
+ employee={currentEmployee}
261
+ className="size-5"
262
+ />
263
+ <span className="truncate">
264
+ {currentEmployee.nickname}
265
+ </span>
266
+ </span>
267
+ </SelectValue>
268
+ </SelectTrigger>
269
+ <SelectContent side="top" className="max-h-[400px] min-w-72">
270
+ {visibleEmployees.map((employee) => (
271
+ <SelectItem
272
+ key={employee.username}
273
+ value={employee.username}
274
+ className="py-1.5"
275
+ >
276
+ <HoverCard
277
+ open={
278
+ employeeSelectOpen &&
279
+ previewedEmployee === employee.username
280
+ }
281
+ onOpenChange={(open) => {
282
+ if (!employeeSelectOpen) return;
283
+ setPreviewedEmployee(
284
+ open ? employee.username : undefined
285
+ );
286
+ }}
287
+ >
288
+ <HoverCardTrigger
289
+ delay={250}
290
+ closeDelay={100}
291
+ render={
292
+ <span className="flex min-w-0 flex-1 items-center gap-2" />
293
+ }
294
+ >
295
+ <AIEmployeeAvatar
296
+ employee={employee}
297
+ className="size-9"
298
+ />
299
+ <span className="min-w-0">
300
+ <span className="block font-medium">
301
+ {employee.nickname}
302
+ </span>
303
+ {employee.position ? (
304
+ <span className="block truncate text-xs text-muted-foreground">
305
+ {employee.position}
306
+ </span>
307
+ ) : null}
308
+ </span>
309
+ </HoverCardTrigger>
310
+ <HoverCardContent
311
+ side="left"
312
+ align="start"
313
+ sideOffset={8}
314
+ className="w-65 p-3 data-closed:hidden"
315
+ >
316
+ <AIEmployeeProfile employee={employee} />
317
+ </HoverCardContent>
318
+ </HoverCard>
319
+ </SelectItem>
320
+ ))}
321
+ </SelectContent>
322
+ </Select>
323
+ ) : null}
324
+ {showModelSelector ? (
325
+ <Select
326
+ value={getAIModelKey(currentModel)}
327
+ onValueChange={(value) => value && selectModel(value)}
328
+ >
329
+ <SelectTrigger
330
+ size="sm"
331
+ title={currentModel.label}
332
+ className="w-28 max-w-28 shrink-0 border-0 bg-muted/60 px-1.5 shadow-none"
333
+ >
334
+ <SelectValue className="min-w-0 overflow-hidden">
335
+ <span className="block min-w-0 truncate">
336
+ {currentModel.label}
337
+ </span>
338
+ </SelectValue>
339
+ </SelectTrigger>
340
+ <SelectContent side="top" className="max-h-[400px] min-w-56">
341
+ <AIModelSelectOptions models={models} />
342
+ </SelectContent>
343
+ </Select>
344
+ ) : null}
345
+ </div>
346
+ {busy ? (
347
+ <InputGroupButton
348
+ size="icon-sm"
349
+ variant="default"
350
+ className="rounded-lg"
351
+ aria-label="Stop generating"
352
+ onClick={() => void stop()}
353
+ >
354
+ <Square className="size-3 fill-current" />
355
+ </InputGroupButton>
356
+ ) : (
357
+ <InputGroupButton
358
+ size="icon-sm"
359
+ variant="default"
360
+ className="rounded-lg"
361
+ aria-label="Send message"
362
+ disabled={
363
+ uploadingAttachments ||
364
+ (!draft.trim() &&
365
+ !attachments.some(
366
+ (attachment) => attachment.status === "done"
367
+ ) &&
368
+ !workContext.length)
369
+ }
370
+ onClick={() => void send()}
371
+ >
372
+ <ArrowUp />
373
+ </InputGroupButton>
374
+ )}
375
+ </InputGroupAddon>
376
+ </InputGroup>
377
+ {enableAttachments ? (
378
+ <input
379
+ ref={fileInputRef}
380
+ type="file"
381
+ multiple
382
+ className="hidden"
383
+ tabIndex={-1}
384
+ aria-hidden="true"
385
+ onChange={(event) => {
386
+ const files = Array.from(event.target.files ?? []);
387
+ if (files.length) void uploadFiles(files);
388
+ event.target.value = "";
389
+ }}
390
+ />
391
+ ) : null}
392
+ </div>
393
+ {disclaimer !== false ? (
394
+ <p className="my-2.5 px-4 text-center text-[11px] text-muted-foreground">
395
+ {disclaimer}
396
+ </p>
397
+ ) : (
398
+ <div className="h-2" />
399
+ )}
400
+ </footer>
401
+ );
402
+ }
403
+
404
+ function AIEmployeeProfile({ employee }: { employee: AIEmployee }) {
405
+ const description = employee.bio ?? employee.description;
406
+
407
+ return (
408
+ <div>
409
+ <div className="flex items-center gap-3">
410
+ <AIEmployeeAvatar employee={employee} className="size-10" />
411
+ <div className="min-w-0">
412
+ <div className="truncate text-sm font-medium">
413
+ {employee.nickname}
414
+ </div>
415
+ {employee.position ? (
416
+ <div className="truncate text-xs text-muted-foreground">
417
+ {employee.position}
418
+ </div>
419
+ ) : null}
420
+ </div>
421
+ </div>
422
+ {description ? (
423
+ <p className="mt-3 border-t pt-3 text-xs leading-5 text-muted-foreground">
424
+ {description}
425
+ </p>
426
+ ) : null}
427
+ </div>
428
+ );
429
+ }
@@ -0,0 +1,51 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import { useAIChatBase } from "../../providers";
3
+ import { ArrowUpRight, Send, TextCursorInput } from "lucide-react";
4
+ import { AIEmployeeAvatar } from "./ai-employee-avatar";
5
+
6
+ export function ChatEmptyState() {
7
+ const { currentEmployee, availableTasks, runTask } = useAIChatBase();
8
+
9
+ return (
10
+ <div className="flex min-h-full items-center justify-center px-6 py-12">
11
+ <div className="w-full max-w-sm text-center">
12
+ <AIEmployeeAvatar
13
+ employee={currentEmployee}
14
+ className="mx-auto size-12"
15
+ />
16
+ <p className="mx-auto mt-4 max-w-xs text-sm leading-6 text-muted-foreground">
17
+ {currentEmployee.greeting ??
18
+ `Hi, I’m ${currentEmployee.nickname}. How can I help?`}
19
+ </p>
20
+ {availableTasks.length ? (
21
+ <div className="mt-6 grid gap-2 text-left">
22
+ {availableTasks.map((task, index) => {
23
+ const Icon = task.autoSend ? Send : TextCursorInput;
24
+ return (
25
+ <Button
26
+ key={`${task.title ?? "task"}-${index}`}
27
+ variant="outline"
28
+ className="h-auto justify-start gap-3 whitespace-normal px-3 py-3 text-left font-normal"
29
+ onClick={() => runTask(task)}
30
+ >
31
+ <Icon className="size-4 text-muted-foreground" />
32
+ <span className="min-w-0 flex-1">
33
+ <span className="block">
34
+ {task.title ?? task.message?.user ?? `Task ${index + 1}`}
35
+ </span>
36
+ <span className="mt-0.5 block text-[11px] text-muted-foreground">
37
+ {task.autoSend
38
+ ? "Send automatically"
39
+ : "Fill the composer before sending"}
40
+ </span>
41
+ </span>
42
+ <ArrowUpRight className="size-3.5 text-muted-foreground" />
43
+ </Button>
44
+ );
45
+ })}
46
+ </div>
47
+ ) : null}
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,88 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import {
3
+ Tooltip,
4
+ TooltipContent,
5
+ TooltipTrigger,
6
+ } from "@/components/ui/tooltip";
7
+ import { useAIChatBase } from "../../providers";
8
+ import { Menu, PanelLeftClose, PlusCircle } from "lucide-react";
9
+ import type { ReactNode } from "react";
10
+ import { UserPromptEditor } from "./user-prompt-editor";
11
+
12
+ export function ChatHeader({
13
+ actions,
14
+ showConversationToggle = true,
15
+ showNewConversation = true,
16
+ showUserPrompt = true,
17
+ }: {
18
+ actions?: ReactNode;
19
+ showConversationToggle?: boolean;
20
+ showNewConversation?: boolean;
21
+ showUserPrompt?: boolean;
22
+ }) {
23
+ const {
24
+ activeConversation,
25
+ conversations,
26
+ conversationListOpen,
27
+ setConversationListOpen,
28
+ startNewConversation,
29
+ } = useAIChatBase();
30
+ const unreadCount = conversations.filter(
31
+ (conversation) => conversation.unread
32
+ ).length;
33
+
34
+ return (
35
+ <header className="grid h-12 shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center border-b bg-card px-2.5">
36
+ <div className="flex min-w-8 items-center">
37
+ {showConversationToggle ? (
38
+ <Tooltip>
39
+ <TooltipTrigger
40
+ render={
41
+ <Button
42
+ variant="ghost"
43
+ size="icon-sm"
44
+ aria-label="Conversation list"
45
+ onClick={() => setConversationListOpen(!conversationListOpen)}
46
+ />
47
+ }
48
+ >
49
+ <span className="relative">
50
+ {conversationListOpen ? <PanelLeftClose /> : <Menu />}
51
+ {!conversationListOpen && unreadCount > 0 ? (
52
+ <span className="absolute -right-1 -top-1 size-2 rounded-full bg-destructive ring-2 ring-card" />
53
+ ) : null}
54
+ </span>
55
+ </TooltipTrigger>
56
+ <TooltipContent>Conversation list</TooltipContent>
57
+ </Tooltip>
58
+ ) : null}
59
+ </div>
60
+
61
+ <div className="pointer-events-none min-w-0 truncate px-2 text-center text-sm font-medium">
62
+ {activeConversation?.title ?? "New conversation"}
63
+ </div>
64
+
65
+ <div className="flex shrink-0 items-center gap-0.5">
66
+ {showUserPrompt ? <UserPromptEditor /> : null}
67
+ {showNewConversation ? (
68
+ <Tooltip>
69
+ <TooltipTrigger
70
+ render={
71
+ <Button
72
+ variant="ghost"
73
+ size="icon-sm"
74
+ aria-label="New conversation"
75
+ onClick={startNewConversation}
76
+ />
77
+ }
78
+ >
79
+ <PlusCircle />
80
+ </TooltipTrigger>
81
+ <TooltipContent>New conversation</TooltipContent>
82
+ </Tooltip>
83
+ ) : null}
84
+ {actions}
85
+ </div>
86
+ </header>
87
+ );
88
+ }