@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,142 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import { cn } from "@/lib/utils";
3
+ import { ChevronDown, ChevronRight, LoaderCircle } from "lucide-react";
4
+ import { useState } from "react";
5
+ import {
6
+ useAI,
7
+ type AISubAgentConversation as AISubAgentConversationType,
8
+ type AIToolCallDecision,
9
+ } from "../../providers";
10
+ import { AIEmployeeAvatar } from "./ai-employee-avatar";
11
+ import { MarkdownMessage } from "./markdown-message";
12
+ import { ReasoningPanel } from "./reasoning-panel";
13
+ import {
14
+ getToolCallName,
15
+ isToolCallPart,
16
+ ToolCallCard,
17
+ } from "./tool-call-card";
18
+
19
+ export function SubAgentConversation({
20
+ conversation,
21
+ onToolCallDecision,
22
+ status = "ready",
23
+ decideToolCall,
24
+ focusComposer,
25
+ }: {
26
+ conversation: AISubAgentConversationType;
27
+ onToolCallDecision?: (decision: AIToolCallDecision) => void | Promise<void>;
28
+ status?: "submitted" | "streaming" | "ready" | "error";
29
+ decideToolCall?: (decision: AIToolCallDecision) => Promise<void>;
30
+ focusComposer?: () => void;
31
+ }) {
32
+ const ai = useAI();
33
+ const completed = conversation.status === "completed";
34
+ const [expanded, setExpanded] = useState(true);
35
+ const employee = ai.employees.find(
36
+ (item) => item.username === conversation.username
37
+ ) ?? {
38
+ username: conversation.username,
39
+ nickname: conversation.username,
40
+ };
41
+ const messages =
42
+ conversation.messages[0]?.role === "user"
43
+ ? conversation.messages.slice(1)
44
+ : conversation.messages;
45
+ const interactionPending = status === "streaming" || status === "submitted";
46
+
47
+ return (
48
+ <section className="rounded-xl border border-dashed bg-muted/15">
49
+ <Button
50
+ type="button"
51
+ variant="ghost"
52
+ className="h-auto w-full justify-start rounded-xl px-3 py-2 text-left"
53
+ onClick={() => setExpanded((current) => !current)}
54
+ >
55
+ {expanded ? (
56
+ <ChevronDown className="size-4 shrink-0" />
57
+ ) : (
58
+ <ChevronRight className="size-4 shrink-0" />
59
+ )}
60
+ <AIEmployeeAvatar employee={employee} className="size-6" />
61
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">
62
+ {employee.nickname}
63
+ </span>
64
+ <span className="flex shrink-0 items-center gap-1.5 text-xs font-normal text-muted-foreground">
65
+ {!completed ? <LoaderCircle className="size-3 animate-spin" /> : null}
66
+ {completed ? "Completed" : "Working"}
67
+ </span>
68
+ </Button>
69
+ {expanded ? (
70
+ <div className="space-y-3 border-t border-dashed px-3 py-3">
71
+ {messages.flatMap((message) =>
72
+ message.parts.map((part, index) => {
73
+ if (part.type === "reasoning") {
74
+ return (
75
+ <ReasoningPanel
76
+ key={`${message.id}-reasoning-${index}`}
77
+ streaming={part.state === "streaming"}
78
+ >
79
+ {part.text}
80
+ </ReasoningPanel>
81
+ );
82
+ }
83
+ if (part.type === "text") {
84
+ return (
85
+ <div
86
+ key={`${message.id}-text-${index}`}
87
+ className="ai-markdown text-sm leading-6 text-foreground"
88
+ >
89
+ <MarkdownMessage>{part.text}</MarkdownMessage>
90
+ </div>
91
+ );
92
+ }
93
+ if (part.type === "data-subAgent") {
94
+ return (
95
+ <SubAgentConversation
96
+ key={part.id ?? part.data.sessionId}
97
+ conversation={part.data}
98
+ onToolCallDecision={onToolCallDecision}
99
+ status={status}
100
+ decideToolCall={decideToolCall}
101
+ focusComposer={focusComposer}
102
+ />
103
+ );
104
+ }
105
+ if (!isToolCallPart(part)) return [];
106
+ return (
107
+ <ToolCallCard
108
+ key={part.toolCallId}
109
+ part={part}
110
+ approval={message.metadata?.toolApprovals?.[part.toolCallId]}
111
+ disabled={interactionPending}
112
+ onRevise={focusComposer}
113
+ onDecision={async (decision, input) => {
114
+ const toolDecision = {
115
+ messageId: message.id,
116
+ toolCallId: part.toolCallId,
117
+ toolName: getToolCallName(part),
118
+ decision,
119
+ input,
120
+ } satisfies AIToolCallDecision;
121
+ await decideToolCall?.(toolDecision);
122
+ await onToolCallDecision?.(toolDecision);
123
+ }}
124
+ />
125
+ );
126
+ })
127
+ )}
128
+ {!messages.length ? (
129
+ <div
130
+ className={cn(
131
+ "text-xs text-muted-foreground",
132
+ !completed && "animate-pulse"
133
+ )}
134
+ >
135
+ {completed ? "No visible output." : "Starting delegated work…"}
136
+ </div>
137
+ ) : null}
138
+ </div>
139
+ ) : null}
140
+ </section>
141
+ );
142
+ }
@@ -0,0 +1,316 @@
1
+ import {
2
+ Collapsible,
3
+ CollapsibleContent,
4
+ CollapsibleTrigger,
5
+ } from "@/components/ui/collapsible";
6
+ import { Button } from "@/components/ui/button";
7
+ import { cn } from "@/lib/utils";
8
+ import type { AIChatMessage, AIToolCallApproval } from "../../providers";
9
+ import {
10
+ Check,
11
+ ChevronDown,
12
+ CircleAlert,
13
+ LoaderCircle,
14
+ Wrench,
15
+ } from "lucide-react";
16
+ import { useEffect, useState, type ReactNode } from "react";
17
+ import { useAIToolRenderer } from "../tools/tool-renderer-provider";
18
+
19
+ export type ToolCallPart = Extract<
20
+ AIChatMessage["parts"][number],
21
+ { type: `tool-${string}` | "dynamic-tool" }
22
+ >;
23
+
24
+ export function isToolCallPart(
25
+ part: AIChatMessage["parts"][number]
26
+ ): part is ToolCallPart {
27
+ return part.type === "dynamic-tool" || part.type.startsWith("tool-");
28
+ }
29
+
30
+ export const getToolCallName = (part: ToolCallPart) =>
31
+ part.type === "dynamic-tool" ? part.toolName : part.type.slice(5);
32
+
33
+ const toolLabel = (part: ToolCallPart) =>
34
+ getToolCallName(part)
35
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
36
+ .replace(/[-_]+/g, " ")
37
+ .replace(/^./, (character) => character.toUpperCase());
38
+
39
+ const formatValue = (value: unknown) => {
40
+ if (typeof value === "string") return value;
41
+ try {
42
+ return JSON.stringify(value, null, 2) ?? String(value);
43
+ } catch {
44
+ return String(value);
45
+ }
46
+ };
47
+
48
+ const getStatus = (part: ToolCallPart) => {
49
+ switch (part.state) {
50
+ case "input-streaming":
51
+ return {
52
+ label: "Preparing",
53
+ icon: LoaderCircle,
54
+ tone: "text-muted-foreground",
55
+ };
56
+ case "input-available":
57
+ return {
58
+ label: "Running",
59
+ icon: LoaderCircle,
60
+ tone: "text-muted-foreground",
61
+ };
62
+ case "output-error":
63
+ return {
64
+ label: "Failed",
65
+ icon: CircleAlert,
66
+ tone: "text-destructive",
67
+ };
68
+ case "output-available":
69
+ return {
70
+ label: "Completed",
71
+ icon: Check,
72
+ tone: "text-foreground",
73
+ };
74
+ }
75
+ };
76
+
77
+ const approvalFromPart = (
78
+ part: ToolCallPart
79
+ ): AIToolCallApproval | undefined => {
80
+ if (!("callProviderMetadata" in part)) return undefined;
81
+ const metadata = part.callProviderMetadata?.nocobase;
82
+ if (!metadata || typeof metadata !== "object") return undefined;
83
+ const requiresApproval = (metadata as { requiresApproval?: unknown })
84
+ .requiresApproval;
85
+ return requiresApproval === true
86
+ ? { required: true, status: "pending" }
87
+ : undefined;
88
+ };
89
+
90
+ export type NocoBaseToolCallMetadata = {
91
+ autoApprove?: boolean;
92
+ invokeStatus?: string;
93
+ messageId?: string;
94
+ requiresApproval?: boolean;
95
+ selectedSuggestion?: string;
96
+ status?: string;
97
+ };
98
+
99
+ export const getNocoBaseToolCallMetadata = (part: ToolCallPart) => {
100
+ if (!("callProviderMetadata" in part)) return undefined;
101
+ const metadata = part.callProviderMetadata?.nocobase;
102
+ return metadata && typeof metadata === "object"
103
+ ? (metadata as NocoBaseToolCallMetadata)
104
+ : undefined;
105
+ };
106
+
107
+ type ToolCallCardProps = {
108
+ part: ToolCallPart;
109
+ approval?: AIToolCallApproval;
110
+ onDecision?: (
111
+ decision: "approve" | "reject" | "edit",
112
+ input?: unknown
113
+ ) => void | Promise<void>;
114
+ onRevise?: () => void;
115
+ inlineActions?: ReactNode;
116
+ disabled?: boolean;
117
+ };
118
+
119
+ export function ToolCallCard({
120
+ part,
121
+ approval,
122
+ onDecision,
123
+ onRevise,
124
+ inlineActions,
125
+ disabled = false,
126
+ }: ToolCallCardProps) {
127
+ const resolvedApproval = approval ?? approvalFromPart(part);
128
+ const [approvalStatus, setApprovalStatus] = useState(
129
+ resolvedApproval?.status ?? "pending"
130
+ );
131
+ const [deciding, setDeciding] = useState(false);
132
+ const renderer = useAIToolRenderer(getToolCallName(part));
133
+ const Renderer = renderer?.component;
134
+ const [open, setOpen] = useState(
135
+ part.state === "output-error" || Boolean(Renderer)
136
+ );
137
+ const toolStatus = getStatus(part);
138
+ const approvalRequired =
139
+ resolvedApproval?.required === true && approvalStatus === "pending";
140
+ const status = approvalRequired
141
+ ? { label: "Approval required", icon: CircleAlert, tone: "text-foreground" }
142
+ : approvalStatus === "rejected"
143
+ ? { label: "Denied", icon: CircleAlert, tone: "text-muted-foreground" }
144
+ : approvalStatus === "approved" && part.state === "input-available"
145
+ ? { label: "Allowed", icon: Check, tone: "text-foreground" }
146
+ : toolStatus;
147
+ const StatusIcon = status.icon;
148
+ const hasInput = part.input !== undefined;
149
+ const hasError = part.state === "output-error";
150
+
151
+ useEffect(() => {
152
+ setApprovalStatus(resolvedApproval?.status ?? "pending");
153
+ }, [resolvedApproval?.status]);
154
+
155
+ useEffect(() => {
156
+ if (part.state === "output-error") {
157
+ setOpen(true);
158
+ }
159
+ }, [part.state]);
160
+
161
+ const decide = async (
162
+ decision: "approve" | "reject" | "edit",
163
+ input?: unknown
164
+ ) => {
165
+ if (disabled) return;
166
+ setDeciding(true);
167
+ try {
168
+ await onDecision?.(decision, input);
169
+ setApprovalStatus(decision === "reject" ? "rejected" : "approved");
170
+ } finally {
171
+ setDeciding(false);
172
+ }
173
+ };
174
+
175
+ if (Renderer && renderer?.standalone) {
176
+ const specializedDisabled =
177
+ disabled ||
178
+ approvalStatus === "rejected" ||
179
+ (resolvedApproval?.required === true && approvalStatus !== "pending");
180
+ return (
181
+ <div>
182
+ <Renderer
183
+ part={part}
184
+ disabled={specializedDisabled}
185
+ onEdit={(input) => onDecision?.("edit", input)}
186
+ onApprove={() => onDecision?.("approve")}
187
+ onReject={(message) => onDecision?.("reject", message)}
188
+ onRevise={() => onRevise?.()}
189
+ />
190
+ {part.state === "output-error" ? (
191
+ <div className="mt-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
192
+ {part.errorText}
193
+ </div>
194
+ ) : null}
195
+ </div>
196
+ );
197
+ }
198
+
199
+ return (
200
+ <Collapsible
201
+ open={open}
202
+ onOpenChange={setOpen}
203
+ className="overflow-hidden rounded-lg border border-border/70 bg-muted/30"
204
+ >
205
+ <div className="flex items-center">
206
+ <CollapsibleTrigger className="group/tool-call flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-xs font-medium text-muted-foreground outline-none hover:bg-muted/40 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset">
207
+ <Wrench className="size-3.5 shrink-0" />
208
+ <span className="min-w-0 flex-1 truncate">{toolLabel(part)}</span>
209
+ <span
210
+ className={cn(
211
+ "flex shrink-0 items-center gap-1.5 font-normal",
212
+ status.tone
213
+ )}
214
+ >
215
+ <StatusIcon
216
+ className={cn(
217
+ "size-3.5",
218
+ (deciding ||
219
+ (!approvalRequired &&
220
+ approvalStatus === "pending" &&
221
+ (part.state === "input-streaming" ||
222
+ part.state === "input-available"))) &&
223
+ "animate-spin"
224
+ )}
225
+ />
226
+ {deciding ? "Applying…" : status.label}
227
+ </span>
228
+ <ChevronDown className="size-3.5 shrink-0 transition-transform group-data-panel-open/tool-call:rotate-180" />
229
+ </CollapsibleTrigger>
230
+ {inlineActions ? (
231
+ <div className="flex shrink-0 items-center pr-2">{inlineActions}</div>
232
+ ) : null}
233
+ </div>
234
+ {approvalRequired && !renderer?.handlesApproval ? (
235
+ <div className="flex items-center justify-between gap-3 border-t px-3 py-2.5">
236
+ <p className="text-xs text-muted-foreground">
237
+ This tool needs permission before it can run.
238
+ </p>
239
+ <div className="flex shrink-0 items-center gap-2">
240
+ <Button
241
+ variant="ghost"
242
+ size="sm"
243
+ disabled={disabled || deciding}
244
+ onClick={() => void decide("reject")}
245
+ >
246
+ Deny
247
+ </Button>
248
+ <Button
249
+ size="sm"
250
+ disabled={disabled || deciding}
251
+ onClick={() => void decide("approve")}
252
+ >
253
+ Allow
254
+ </Button>
255
+ </div>
256
+ </div>
257
+ ) : null}
258
+ <CollapsibleContent className="border-t">
259
+ {Renderer ? (
260
+ <div className="p-3">
261
+ <Renderer
262
+ part={part}
263
+ disabled={
264
+ disabled ||
265
+ deciding ||
266
+ approvalStatus === "rejected" ||
267
+ (resolvedApproval?.required === true &&
268
+ approvalStatus !== "pending")
269
+ }
270
+ onEdit={(input) => decide("edit", input)}
271
+ onApprove={() => decide("approve")}
272
+ onReject={() => decide("reject")}
273
+ onRevise={() => onRevise?.()}
274
+ />
275
+ </div>
276
+ ) : hasInput ? (
277
+ <ToolCallValue label="Input" value={part.input} />
278
+ ) : null}
279
+ {part.state === "output-error" ? (
280
+ <ToolCallValue label="Error" value={part.errorText} error />
281
+ ) : null}
282
+ {!Renderer && !hasInput && !hasError ? (
283
+ <p className="px-3 py-3 text-xs text-muted-foreground">
284
+ Waiting for tool input…
285
+ </p>
286
+ ) : null}
287
+ </CollapsibleContent>
288
+ </Collapsible>
289
+ );
290
+ }
291
+
292
+ function ToolCallValue({
293
+ label,
294
+ value,
295
+ error = false,
296
+ }: {
297
+ label: string;
298
+ value: unknown;
299
+ error?: boolean;
300
+ }) {
301
+ return (
302
+ <div className="border-b px-3 py-3 last:border-b-0">
303
+ <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
304
+ {label}
305
+ </div>
306
+ <pre
307
+ className={cn(
308
+ "max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-background p-2.5 font-mono text-xs leading-5",
309
+ error && "text-destructive"
310
+ )}
311
+ >
312
+ {formatValue(value)}
313
+ </pre>
314
+ </div>
315
+ );
316
+ }
@@ -0,0 +1,99 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import {
3
+ Popover,
4
+ PopoverContent,
5
+ PopoverDescription,
6
+ PopoverHeader,
7
+ PopoverTitle,
8
+ PopoverTrigger,
9
+ } from "@/components/ui/popover";
10
+ import { Textarea } from "@/components/ui/textarea";
11
+ import {
12
+ Tooltip,
13
+ TooltipContent,
14
+ TooltipTrigger,
15
+ } from "@/components/ui/tooltip";
16
+ import { MessageSquareText } from "lucide-react";
17
+ import { useEffect, useState } from "react";
18
+ import { useAIChatBase } from "../../providers";
19
+
20
+ export function UserPromptEditor() {
21
+ const { currentEmployee, saveUserPrompt } = useAIChatBase();
22
+ const [open, setOpen] = useState(false);
23
+ const [prompt, setPrompt] = useState("");
24
+ const [saving, setSaving] = useState(false);
25
+ const [error, setError] = useState<string>();
26
+
27
+ useEffect(() => {
28
+ if (!open) return;
29
+ setPrompt(currentEmployee.userConfig?.prompt ?? "");
30
+ setError(undefined);
31
+ }, [currentEmployee.userConfig?.prompt, currentEmployee.username, open]);
32
+
33
+ const save = async () => {
34
+ setSaving(true);
35
+ setError(undefined);
36
+ try {
37
+ await saveUserPrompt(prompt);
38
+ setOpen(false);
39
+ } catch (saveError) {
40
+ setError(
41
+ saveError instanceof Error ? saveError.message : "Unable to save prompt"
42
+ );
43
+ } finally {
44
+ setSaving(false);
45
+ }
46
+ };
47
+
48
+ return (
49
+ <Popover open={open} onOpenChange={setOpen}>
50
+ <Tooltip>
51
+ <TooltipTrigger
52
+ render={
53
+ <PopoverTrigger
54
+ render={
55
+ <Button
56
+ variant="ghost"
57
+ size="icon-sm"
58
+ aria-label="Personalized prompt"
59
+ />
60
+ }
61
+ />
62
+ }
63
+ >
64
+ <MessageSquareText />
65
+ </TooltipTrigger>
66
+ <TooltipContent>Personalized prompt</TooltipContent>
67
+ </Tooltip>
68
+ <PopoverContent
69
+ align="end"
70
+ sideOffset={8}
71
+ className="w-[380px] gap-3 p-4"
72
+ >
73
+ <PopoverHeader>
74
+ <PopoverTitle>Personalized prompt</PopoverTitle>
75
+ <PopoverDescription>
76
+ Add instructions that this AI employee should follow when working
77
+ with you.
78
+ </PopoverDescription>
79
+ </PopoverHeader>
80
+ <Textarea
81
+ value={prompt}
82
+ onChange={(event) => setPrompt(event.target.value)}
83
+ rows={5}
84
+ className="max-h-52 min-h-28 resize-y"
85
+ placeholder="For example: Keep answers concise and ask before changing data."
86
+ />
87
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
88
+ <div className="flex justify-end gap-2">
89
+ <Button variant="outline" size="sm" onClick={() => setOpen(false)}>
90
+ Cancel
91
+ </Button>
92
+ <Button size="sm" disabled={saving} onClick={() => void save()}>
93
+ {saving ? "Saving…" : "Save"}
94
+ </Button>
95
+ </div>
96
+ </PopoverContent>
97
+ </Popover>
98
+ );
99
+ }
@@ -0,0 +1,114 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from "@/components/ui/dialog";
9
+ import { cn } from "@/lib/utils";
10
+ import { MousePointer2, X } from "lucide-react";
11
+ import { useMemo, useState } from "react";
12
+ import type { AIWorkContextItem } from "../../providers";
13
+
14
+ const formatContextContent = (content: unknown) => {
15
+ if (content === undefined) return "No context payload";
16
+ if (typeof content === "string") return content;
17
+ try {
18
+ return JSON.stringify(content, null, 2);
19
+ } catch {
20
+ return String(content);
21
+ }
22
+ };
23
+
24
+ export function WorkContextChip({
25
+ item,
26
+ onRemove,
27
+ className,
28
+ }: {
29
+ item: AIWorkContextItem;
30
+ onRemove?: () => void;
31
+ className?: string;
32
+ }) {
33
+ const [open, setOpen] = useState(false);
34
+ const title = item.title ?? "Page element";
35
+ const kind = typeof item.kind === "string" ? item.kind : undefined;
36
+ const content = useMemo(
37
+ () => formatContextContent(item.content),
38
+ [item.content]
39
+ );
40
+
41
+ return (
42
+ <>
43
+ <span
44
+ className={cn(
45
+ "flex min-w-0 max-w-full items-center rounded-md border bg-muted/40 text-xs text-muted-foreground",
46
+ className
47
+ )}
48
+ >
49
+ <button
50
+ type="button"
51
+ className="flex min-w-0 items-center gap-1.5 rounded-l-md px-2 py-1 text-left hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
52
+ aria-label={`View context: ${title}`}
53
+ onClick={() => setOpen(true)}
54
+ >
55
+ <MousePointer2 className="size-3.5 shrink-0" />
56
+ <span className="truncate">{title}</span>
57
+ </button>
58
+ {onRemove ? (
59
+ <button
60
+ type="button"
61
+ className="mr-1 rounded-sm p-0.5 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
62
+ aria-label={`Remove ${title}`}
63
+ onClick={onRemove}
64
+ >
65
+ <X className="size-3" />
66
+ </button>
67
+ ) : null}
68
+ </span>
69
+
70
+ <Dialog open={open} onOpenChange={setOpen}>
71
+ <DialogContent className="sm:max-w-xl">
72
+ <DialogHeader className="pr-8">
73
+ <DialogTitle>{title}</DialogTitle>
74
+ <DialogDescription>
75
+ This is the page context included with the message sent to the AI
76
+ employee.
77
+ </DialogDescription>
78
+ </DialogHeader>
79
+
80
+ <div className="flex flex-wrap gap-2 text-xs">
81
+ <span className="rounded-md border bg-muted/40 px-2 py-1">
82
+ Type: {item.type}
83
+ </span>
84
+ {kind ? (
85
+ <span className="rounded-md border bg-muted/40 px-2 py-1">
86
+ Kind: {kind}
87
+ </span>
88
+ ) : null}
89
+ {item.id ? (
90
+ <span className="max-w-full truncate rounded-md border bg-muted/40 px-2 py-1">
91
+ ID: {item.id}
92
+ </span>
93
+ ) : null}
94
+ </div>
95
+
96
+ <div className="space-y-2">
97
+ <div className="text-xs font-medium text-muted-foreground">
98
+ Context payload
99
+ </div>
100
+ <pre className="max-h-[55vh] overflow-auto rounded-lg border bg-muted/30 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
101
+ {content}
102
+ </pre>
103
+ </div>
104
+
105
+ <div className="flex justify-end">
106
+ <Button variant="outline" onClick={() => setOpen(false)}>
107
+ Close
108
+ </Button>
109
+ </div>
110
+ </DialogContent>
111
+ </Dialog>
112
+ </>
113
+ );
114
+ }