@budibase/frontend-core 3.39.23 → 3.39.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@budibase/frontend-core",
3
- "version": "3.39.23",
3
+ "version": "3.39.25",
4
4
  "description": "Budibase frontend core libraries used in builder and client",
5
5
  "author": "Budibase",
6
6
  "license": "MPL-2.0",
@@ -23,5 +23,5 @@
23
23
  "devDependencies": {
24
24
  "vitest": "^4.1.0"
25
25
  },
26
- "gitHead": "833e219692f2ec7892cbeaef8aeaa2732d1ccb27"
26
+ "gitHead": "02e3837724b2102305a6aeb8590004680d403c32"
27
27
  }
@@ -0,0 +1,44 @@
1
+ import {
2
+ type ChatIdentityLink,
3
+ type ChatIdentityLinkProvider,
4
+ } from "@budibase/types"
5
+ import { BaseAPIClient } from "./types"
6
+
7
+ export interface SlackChannel {
8
+ id: string
9
+ name: string
10
+ }
11
+
12
+ export interface MSTeamsChannel {
13
+ id: string
14
+ name: string
15
+ teamId: string
16
+ teamName: string
17
+ }
18
+
19
+ export interface ChatLinksEndpoints {
20
+ fetchChatIdentityLinks: (
21
+ provider?: ChatIdentityLinkProvider
22
+ ) => Promise<ChatIdentityLink[]>
23
+ fetchSlackChannels: (agentId: string) => Promise<SlackChannel[]>
24
+ fetchMSTeamsChannels: (agentId: string) => Promise<MSTeamsChannel[]>
25
+ }
26
+
27
+ export const buildChatLinksEndpoints = (
28
+ API: BaseAPIClient
29
+ ): ChatLinksEndpoints => ({
30
+ fetchChatIdentityLinks: async provider => {
31
+ const query = provider
32
+ ? `?${new URLSearchParams({ provider }).toString()}`
33
+ : ""
34
+ return await API.get({ url: `/api/chat-links${query}` })
35
+ },
36
+ fetchSlackChannels: async agentId => {
37
+ const query = new URLSearchParams({ agentId }).toString()
38
+ return await API.get({ url: `/api/slack-channels?${query}` })
39
+ },
40
+ fetchMSTeamsChannels: async agentId => {
41
+ const query = new URLSearchParams({ agentId }).toString()
42
+ return await API.get({ url: `/api/teams-channels?${query}` })
43
+ },
44
+ })
@@ -0,0 +1,41 @@
1
+ import type {
2
+ EscalationContextDoc,
3
+ EscalationRespondResult,
4
+ EscalationResponse,
5
+ EscalationResult,
6
+ } from "@budibase/types"
7
+ import type { BaseAPIClient } from "./types"
8
+
9
+ export interface EscalationEndpoints {
10
+ fetchEscalationContext: (
11
+ escalationId: string
12
+ ) => Promise<EscalationContextDoc>
13
+ fetchEscalationResult: (escalationId: string) => Promise<EscalationResult>
14
+ resolveEscalation: (
15
+ escalationId: string,
16
+ response: EscalationResponse
17
+ ) => Promise<EscalationRespondResult>
18
+ }
19
+
20
+ export const buildEscalationEndpoints = (
21
+ API: BaseAPIClient
22
+ ): EscalationEndpoints => ({
23
+ fetchEscalationContext: async escalationId => {
24
+ return await API.get({
25
+ url: `/api/escalations/context/${escalationId}`,
26
+ })
27
+ },
28
+
29
+ fetchEscalationResult: async escalationId => {
30
+ return await API.get({
31
+ url: `/api/escalations/${escalationId}/result`,
32
+ })
33
+ },
34
+
35
+ resolveEscalation: async (escalationId, response) => {
36
+ return await API.post({
37
+ url: `/api/escalations/${escalationId}/resolve`,
38
+ body: { response },
39
+ })
40
+ },
41
+ })
package/src/api/index.ts CHANGED
@@ -23,6 +23,7 @@ import { buildFlagEndpoints } from "./flags"
23
23
  import { buildLayoutEndpoints } from "./layouts"
24
24
  import { buildOtherEndpoints } from "./other"
25
25
  import { buildPermissionsEndpoints } from "./permissions"
26
+ import { buildProjectEndpoints } from "./projects"
26
27
  import { buildQueryEndpoints } from "./queries"
27
28
  import { buildRelationshipEndpoints } from "./relationships"
28
29
  import { buildRoleEndpoints } from "./roles"
@@ -51,6 +52,8 @@ import { buildAgentTestEndpoints } from "./agentTests"
51
52
  import { buildAgentLogEndpoints } from "./agentLogs"
52
53
  import { buildAgentRequestEndpoints } from "./agentRequests"
53
54
  import { buildChatAppEndpoints } from "./chatApps"
55
+ import { buildEscalationEndpoints } from "./escalations"
56
+ import { buildChatLinksEndpoints } from "./chatLinks"
54
57
  import { buildFeatureFlagEndpoints } from "./features"
55
58
  import { buildNavigationEndpoints } from "./navigation"
56
59
  import { buildWorkspaceAppEndpoints } from "./workspaceApps"
@@ -323,12 +326,15 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
323
326
  ...buildAgentLogEndpoints(API),
324
327
  ...buildAgentRequestEndpoints(API),
325
328
  ...buildChatAppEndpoints(API),
329
+ ...buildEscalationEndpoints(API),
330
+ ...buildChatLinksEndpoints(API),
326
331
  ...buildFeatureFlagEndpoints(API),
327
332
  deployment: buildDeploymentEndpoints(API),
328
333
  viewV2: buildViewV2Endpoints(API),
329
334
  rowActions: buildRowActionEndpoints(API),
330
335
  oauth2: buildOAuth2Endpoints(API),
331
336
  navigation: buildNavigationEndpoints(API),
337
+ projects: buildProjectEndpoints(API),
332
338
  workspaceApp: buildWorkspaceAppEndpoints(API),
333
339
  workspace: buildWorkspaceFavouriteEndpoints(API),
334
340
  workspaceHome: buildWorkspaceHomeEndpoints(API),
@@ -0,0 +1,78 @@
1
+ import {
2
+ CreateProjectRequest,
3
+ CreateProjectResponse,
4
+ ExportProjectRequest,
5
+ FetchProjectsResponse,
6
+ ImportProjectRequest,
7
+ ImportProjectResponse,
8
+ UpdateProjectRequest,
9
+ UpdateProjectResponse,
10
+ } from "@budibase/types"
11
+ import { BaseAPIClient } from "./types"
12
+
13
+ export interface ProjectEndpoints {
14
+ fetch: () => Promise<FetchProjectsResponse>
15
+ create: (project: CreateProjectRequest) => Promise<CreateProjectResponse>
16
+ exportBundle: (id: string, body?: ExportProjectRequest) => Promise<Response>
17
+ importBundle: (
18
+ file: File,
19
+ body?: ImportProjectRequest
20
+ ) => Promise<ImportProjectResponse>
21
+ update: (project: UpdateProjectRequest) => Promise<UpdateProjectResponse>
22
+ delete: (id: string, rev: string) => Promise<void>
23
+ }
24
+
25
+ export const buildProjectEndpoints = (
26
+ API: BaseAPIClient
27
+ ): ProjectEndpoints => ({
28
+ fetch: async () => {
29
+ return await API.get({
30
+ url: "/api/projects",
31
+ })
32
+ },
33
+ create: async project => {
34
+ return await API.post({
35
+ url: "/api/projects",
36
+ body: project,
37
+ })
38
+ },
39
+ exportBundle: async (id, body) => {
40
+ return await API.post<ExportProjectRequest | undefined, Response>({
41
+ url: `/api/projects/${id}/export`,
42
+ body,
43
+ parseResponse: response => response,
44
+ })
45
+ },
46
+ importBundle: async (file, body) => {
47
+ const formData = new FormData()
48
+ formData.append("file", file)
49
+ for (const [key, value] of Object.entries(body || {})) {
50
+ if (value !== undefined) {
51
+ formData.append(key, value)
52
+ }
53
+ }
54
+ return await API.post<FormData, ImportProjectResponse>({
55
+ url: "/api/projects/import",
56
+ body: formData,
57
+ json: false,
58
+ })
59
+ },
60
+ update: async project => {
61
+ const { _id, _rev, name, description, color } = project
62
+ return await API.put({
63
+ url: `/api/projects/${_id}`,
64
+ body: {
65
+ _id,
66
+ _rev,
67
+ name,
68
+ description,
69
+ color,
70
+ },
71
+ })
72
+ },
73
+ delete: async (id, rev) => {
74
+ return await API.delete({
75
+ url: `/api/projects/${id}/${rev}`,
76
+ })
77
+ },
78
+ })
package/src/api/types.ts CHANGED
@@ -20,6 +20,7 @@ import { MigrationEndpoints } from "./migrations"
20
20
  import { OAuth2Endpoints } from "./oauth2"
21
21
  import { OtherEndpoints } from "./other"
22
22
  import { PermissionEndpoints } from "./permissions"
23
+ import { ProjectEndpoints } from "./projects"
23
24
  import { PluginEndpoins } from "./plugins"
24
25
  import { QueryEndpoints } from "./queries"
25
26
  import { RelationshipEndpoints } from "./relationships"
@@ -39,6 +40,8 @@ import { AgentTestEndpoints } from "./agentTests"
39
40
  import { AgentLogEndpoints } from "./agentLogs"
40
41
  import { AgentRequestEndpoints } from "./agentRequests"
41
42
  import { ChatAppEndpoints } from "./chatApps"
43
+ import { ChatLinksEndpoints } from "./chatLinks"
44
+ import { EscalationEndpoints } from "./escalations"
42
45
  import { NavigationEndpoints } from "./navigation"
43
46
  import { WorkspaceAppEndpoints } from "./workspaceApps"
44
47
  import { ResourceEndpoints } from "./resource"
@@ -125,6 +128,8 @@ export type APIClient = BaseAPIClient &
125
128
  AgentLogEndpoints &
126
129
  AgentRequestEndpoints &
127
130
  ChatAppEndpoints &
131
+ ChatLinksEndpoints &
132
+ EscalationEndpoints &
128
133
  AnalyticsEndpoints &
129
134
  AppEndpoints &
130
135
  AttachmentEndpoints &
@@ -162,6 +167,7 @@ export type APIClient = BaseAPIClient &
162
167
  viewV2: ViewV2Endpoints
163
168
  oauth2: OAuth2Endpoints
164
169
  navigation: NavigationEndpoints
170
+ projects: ProjectEndpoints
165
171
  workspaceApp: WorkspaceAppEndpoints
166
172
  workspace: WorkspaceFavouriteEndpoints
167
173
  workspaceHome: WorkspaceHomeEndpoints
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ import { Body, Button, Icon } from "@budibase/bbui"
3
+ import type { EscalationContextDoc } from "@budibase/types"
4
+
5
+ interface Props {
6
+ title?: string
7
+ summary?: string
8
+ // Live resolution from the poll (not the frozen tool output).
9
+ resolution: EscalationContextDoc["resolution"]
10
+ // Message relayed from resolve ("Response recorded." etc.) - set as soon as
11
+ // the action is taken here, before the poll flips resolution.
12
+ statusMessage?: string
13
+ // Dev-only: whether to show the inline Approve/Reject buttons.
14
+ showApproval?: boolean
15
+ resolving?: boolean
16
+ onApprove?: () => void
17
+ onReject?: () => void
18
+ }
19
+
20
+ let {
21
+ title,
22
+ summary,
23
+ resolution,
24
+ statusMessage,
25
+ showApproval = false,
26
+ resolving = false,
27
+ onApprove,
28
+ onReject,
29
+ }: Props = $props()
30
+
31
+ // Resolved either by this card's action (statusMessage) or elsewhere (poll).
32
+ let isResolved = $derived(!!statusMessage || resolution !== "pending")
33
+ </script>
34
+
35
+ <div class="escalation-card" aria-live="polite">
36
+ <div class="escalation-card-header">
37
+ <Icon name={isResolved ? "check-circle" : "clock"} size="M" />
38
+ <span class="escalation-card-title">{title || "Approval required"}</span>
39
+ {#if showApproval && !isResolved}
40
+ <span class="escalation-card-badge">Test mode</span>
41
+ {/if}
42
+ </div>
43
+
44
+ {#if summary}
45
+ <Body size="S" color="var(--spectrum-global-color-gray-700)">
46
+ {summary}
47
+ </Body>
48
+ {/if}
49
+
50
+ {#if isResolved}
51
+ <Body size="XS" color="var(--spectrum-global-color-gray-600)">
52
+ {statusMessage || "Response recorded."}
53
+ </Body>
54
+ {:else if showApproval}
55
+ <Body size="XS" color="var(--spectrum-global-color-gray-600)">
56
+ Approve or reject here to simulate a reviewer response.
57
+ </Body>
58
+ <div class="escalation-card-actions">
59
+ <Button cta disabled={resolving} on:click={() => onApprove?.()}>
60
+ Approve
61
+ </Button>
62
+ <Button secondary disabled={resolving} on:click={() => onReject?.()}>
63
+ Reject
64
+ </Button>
65
+ </div>
66
+ {:else}
67
+ <Body size="XS" color="var(--spectrum-global-color-gray-600)">
68
+ Awaiting a human response.
69
+ </Body>
70
+ {/if}
71
+ </div>
72
+
73
+ <style>
74
+ .escalation-card {
75
+ align-self: flex-start;
76
+ width: 100%;
77
+ max-width: 100%;
78
+ box-sizing: border-box;
79
+ display: flex;
80
+ flex-direction: column;
81
+ gap: var(--spacing-s);
82
+ padding: var(--spacing-m);
83
+ border: 1px solid var(--spectrum-global-color-gray-300);
84
+ border-radius: 12px;
85
+ background: var(--spectrum-global-color-gray-75);
86
+ }
87
+ .escalation-card-header {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: var(--spacing-s);
91
+ }
92
+ .escalation-card-title {
93
+ font-weight: 600;
94
+ font-size: 14px;
95
+ color: var(--spectrum-global-color-gray-900);
96
+ }
97
+ .escalation-card-badge {
98
+ margin-left: auto;
99
+ border-radius: 999px;
100
+ border: 1px solid var(--spectrum-global-color-blue-400);
101
+ color: var(--spectrum-global-color-blue-700);
102
+ background: var(--spectrum-global-color-blue-100);
103
+ font-size: 11px;
104
+ font-weight: 600;
105
+ line-height: 1;
106
+ padding: 4px 8px;
107
+ }
108
+ .escalation-card-actions {
109
+ display: flex;
110
+ gap: var(--spacing-s);
111
+ margin-top: var(--spacing-xs);
112
+ }
113
+ </style>
@@ -11,6 +11,8 @@
11
11
  ChatConversation,
12
12
  DraftChatConversation,
13
13
  AgentMessageMetadata,
14
+ EscalationContextDoc,
15
+ EscalationRespondResult,
14
16
  } from "@budibase/types"
15
17
  import { Header } from "@budibase/shared-core"
16
18
  import { tick, untrack } from "svelte"
@@ -19,6 +21,7 @@
19
21
  import { formatToolName } from "../../utils/aiTools"
20
22
  import ReasoningStatus from "./ReasoningStatus.svelte"
21
23
  import ContextUsage from "./ContextUsage.svelte"
24
+ import EscalationCard from "./EscalationCard.svelte"
22
25
  import {
23
26
  DefaultChatTransport,
24
27
  isTextUIPart,
@@ -38,6 +41,20 @@
38
41
  onchatsaved?: (_event: {
39
42
  detail: { chatId?: string; chat: ChatConversationLike }
40
43
  }) => void
44
+ // Fired when an escalation parks; the consumer polls the outcome and
45
+ // injects it via appendAssistantMessage.
46
+ onEscalationPending?: (_detail: { escalationId: string }) => void
47
+ // Live resolution per escalationId (from the poll) - drives the card state.
48
+ escalationState?: Record<
49
+ string,
50
+ { resolution: EscalationContextDoc["resolution"] }
51
+ >
52
+ // Dev-only: show the inline Approve/Reject buttons on the escalation card.
53
+ showInlineApproval?: boolean
54
+ onResolve?: (
55
+ _escalationId: string,
56
+ _accepted: boolean
57
+ ) => Promise<EscalationRespondResult | undefined>
41
58
  isAgentPreviewChat?: boolean
42
59
  readOnly?: boolean
43
60
  readOnlyReason?: "disabled" | "deleted" | "offline"
@@ -50,11 +67,47 @@
50
67
  conversationStarters = [],
51
68
  initialPrompt = "",
52
69
  onchatsaved,
70
+ onEscalationPending,
71
+ escalationState,
72
+ showInlineApproval = false,
73
+ onResolve,
53
74
  isAgentPreviewChat = false,
54
75
  readOnly = false,
55
76
  readOnlyReason,
56
77
  }: Props = $props()
57
78
 
79
+ // Per-escalation in-flight flag + the message relayed from resolve, so the
80
+ // card shows the real outcome on click rather than a static label.
81
+ let resolvingEscalations = $state<Record<string, boolean>>({})
82
+ let resolveMessages = $state<Record<string, string>>({})
83
+ const handleResolve = async (escalationId: string, accepted: boolean) => {
84
+ resolvingEscalations = { ...resolvingEscalations, [escalationId]: true }
85
+ try {
86
+ const result = await onResolve?.(escalationId, accepted)
87
+ if (result?.message) {
88
+ resolveMessages = { ...resolveMessages, [escalationId]: result.message }
89
+ }
90
+ } finally {
91
+ resolvingEscalations = { ...resolvingEscalations, [escalationId]: false }
92
+ }
93
+ }
94
+
95
+ // The escalate part's input/output are loosely typed by the AI SDK, so the
96
+ // casts live here rather than cluttering the template.
97
+ const escalationCardProps = (part: { input?: unknown; output?: unknown }) => {
98
+ const output = part.output as { escalationId?: string } | undefined
99
+ const input = part.input as { title?: string; summary?: string } | undefined
100
+ const escalationId = output?.escalationId
101
+ return {
102
+ escalationId,
103
+ title: input?.title,
104
+ summary: input?.summary,
105
+ resolution:
106
+ (escalationId && escalationState?.[escalationId]?.resolution) ||
107
+ "pending",
108
+ }
109
+ }
110
+
58
111
  let API = $state(
59
112
  createAPIClient({
60
113
  attachHeaders: headers => {
@@ -322,6 +375,48 @@
322
375
  let messages = $derived(chatInstance.messages)
323
376
  let lastMessage = $derived(messages[messages.length - 1])
324
377
 
378
+ // Notify the consumer of every unseen parked escalation. The escalate part's
379
+ // output stays frozen at pending_approval, so notifying only the first would
380
+ // shadow later escalations.
381
+ const notifiedEscalations = new Set<string>()
382
+ let pendingEscalationIds = $derived.by(() => {
383
+ const ids: string[] = []
384
+ for (const message of messages) {
385
+ for (const part of message.parts ?? []) {
386
+ if (
387
+ !isToolUIPart(part) ||
388
+ getToolName(part) !== "escalate" ||
389
+ part.state !== "output-available"
390
+ ) {
391
+ continue
392
+ }
393
+ const output = part.output as
394
+ | { status?: string; escalationId?: string }
395
+ | undefined
396
+ if (output?.status === "pending_approval" && output.escalationId) {
397
+ ids.push(output.escalationId)
398
+ }
399
+ }
400
+ }
401
+ return ids
402
+ })
403
+ $effect(() => {
404
+ for (const escalationId of pendingEscalationIds) {
405
+ if (!notifiedEscalations.has(escalationId)) {
406
+ notifiedEscalations.add(escalationId)
407
+ onEscalationPending?.({ escalationId })
408
+ }
409
+ }
410
+ })
411
+
412
+ // Injects the polled resume outcome as a plain assistant turn - the agent's
413
+ // own reply self-frames the approval.
414
+ export function appendAssistantMessage(
415
+ message: UIMessage<AgentMessageMetadata>
416
+ ) {
417
+ chatInstance.messages = [...chatInstance.messages, message]
418
+ }
419
+
325
420
  let lastAssistantUsage = $derived(
326
421
  messages.findLast(m => m.role === "assistant" && m.metadata?.usage)
327
422
  ?.metadata?.usage
@@ -678,6 +773,24 @@
678
773
  {#each message.parts ?? [] as part, partIndex}
679
774
  {#if isTextUIPart(part)}
680
775
  <MarkdownViewer value={part.text} />
776
+ {:else if isToolUIPart(part) && getToolName(part) === "escalate"}
777
+ {@const card = escalationCardProps(part)}
778
+ <EscalationCard
779
+ title={card.title}
780
+ summary={card.summary}
781
+ resolution={card.resolution}
782
+ statusMessage={card.escalationId
783
+ ? resolveMessages[card.escalationId]
784
+ : undefined}
785
+ showApproval={showInlineApproval}
786
+ resolving={!!card.escalationId &&
787
+ !!resolvingEscalations[card.escalationId]}
788
+ onApprove={() =>
789
+ card.escalationId && handleResolve(card.escalationId, true)}
790
+ onReject={() =>
791
+ card.escalationId &&
792
+ handleResolve(card.escalationId, false)}
793
+ />
681
794
  {:else if isToolUIPart(part)}
682
795
  {@const rawToolName = getToolName(part)}
683
796
  {@const displayToolName = formatToolName(