@adminforth/agent 2.9.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -424,6 +424,10 @@ approval interrupt: generation pauses and the UI shows an approve/reject prompt.
424
424
  resolves it via `POST /agent/approval`, and the run resumes (or, on reject, the model is
425
425
  told the action was declined).
426
426
 
427
+ The prompt names the resource and columns by their labels and lists every value the tool
428
+ would write; for `update_record` each field shows its current value next to the new one.
429
+ Values of `masked` and `backendOnly` columns are never shown.
430
+
427
431
  Where the pending approval lives depends on your setup: with `checkpointResource` configured
428
432
  the LangGraph checkpoint is authoritative, so a resume survives a restart and works across
429
433
  instances; with the in-memory fallback the state is held per process instance.
@@ -3,6 +3,7 @@ import type { AgentSessionStore } from "../persistence/sessionStore.js";
3
3
  import type { SteerBuffer } from "../domain/steerBuffer.js";
4
4
  import type { PluginOptions } from "../types.js";
5
5
  import type { LlmPort } from "./ports.js";
6
+ import { type ApprovalDescription } from "../tools/approvalFormatter.js";
6
7
  import type { BaseAgentTurnInput, DebugSink, HandleTurnInput, HandleEditTurnInput, PendingInterrupt, RunAndPersistAgentResponseInput, RunAndPersistAgentResponseResult } from "../domain/turnTypes.js";
7
8
  /**
8
9
  * Build the provider-agnostic resume payload for a human-in-the-loop turn.
@@ -78,7 +79,7 @@ export declare class RunTurnUseCase {
78
79
  * page reload would otherwise leave the user with no way to answer it. Empty when
79
80
  * nothing is pending.
80
81
  */
81
- getPendingApprovals(sessionId: string, modeName?: string | null): Promise<string[]>;
82
+ getPendingApprovals(sessionId: string, modeName?: string | null): Promise<ApprovalDescription[]>;
82
83
  private requiresSessionOwnership;
83
84
  /** Verify the session exists and belongs to the requesting admin user. */
84
85
  assertSessionOwnership(sessionId: string, adminUser: BaseAgentTurnInput["adminUser"]): Promise<void>;
@@ -125,9 +125,9 @@ export class RunTurnUseCase {
125
125
  /** Describe pending tool calls in plain language an admin (not a developer) can act on. */
126
126
  describeApprovals(interrupts) {
127
127
  const adminforth = this.deps.getAdminforth();
128
- return interrupts
128
+ return Promise.all(interrupts
129
129
  .flatMap((interrupt) => interrupt.requests)
130
- .map((request) => formatApprovalRequest(adminforth, request));
130
+ .map((request) => formatApprovalRequest(adminforth, request)));
131
131
  }
132
132
  /**
133
133
  * Describe what a session is still waiting for approval on, so a client that (re)loads
@@ -322,7 +322,7 @@ export class RunTurnUseCase {
322
322
  type: "interrupt",
323
323
  sessionId: prepared.sessionId,
324
324
  interrupt,
325
- approvals: this.describeApprovals(descriptors),
325
+ approvals: yield this.describeApprovals(descriptors),
326
326
  }));
327
327
  });
328
328
  }
@@ -1,17 +1,18 @@
1
1
  <template>
2
2
  <div
3
- ref="promptInput"
4
- class="w-full mb-2 flex items-center justify-center px-2 bg-transparent relative translate-x-[-50%] left-1/2"
5
- :style="{
3
+ ref="promptInput"
4
+ class="w-full mb-3 px-3 relative translate-x-[-50%] left-1/2"
5
+ :style="{
6
6
  maxWidth: agentStore.isFullScreen ? remToPx(agentStore.MAX_WIDTH)+'px' : '100%',
7
7
  transition: `transform ${agentTransitions.TRANSITION_DURATION}ms ease-in-out`
8
- }"
8
+ }"
9
9
  >
10
10
  <SteerQueue />
11
11
  <!-- <SteerDebugPanel /> -->
12
12
  <div
13
- class="w-full border rounded-lg flex flex-col"
14
- :class="agentStore.isAudioChatMode ? 'border-none mt-8' : 'border dark:bg-gray-700'"
13
+ class="w-full flex flex-col rounded-2xl border shadow-sm transition-colors duration-150
14
+ border-gray-300 bg-white focus-within:border-gray-400
15
+ dark:border-gray-600 dark:bg-gray-800 dark:focus-within:border-gray-500"
15
16
  >
16
17
  <textarea
17
18
  v-if="!agentStore.isAudioChatMode"
@@ -20,36 +21,42 @@
20
21
  rows="1"
21
22
  @input="autoResize"
22
23
  :class="[
23
- 'h-8 px-4 py-3 rounded-xl rounded-b-none w-full resize-none overflow-hidden text-lightInputText dark:text-darkInputText rounded-md bg-transparent text-sm bg-gray-50 dark:bg-gray-700 dark:border-gray-600 focus:outline-none',
24
+ `block w-full px-3.5 pt-3 pb-1 resize-none overflow-hidden bg-transparent border-none text-sm leading-6
25
+ focus:outline-none focus:ring-0
26
+ text-lightInputText placeholder:text-gray-500 dark:text-darkInputText dark:placeholder:text-gray-400`,
24
27
  { '!text-base': coreStore.isIos }
25
28
  ]"
26
29
  :placeholder="placeholderText"
27
30
  @keydown.enter.exact.prevent="sendMessage"
28
31
  @keydown.esc="onEscape"
29
32
  />
30
- <div class="flex items-center justify-between px-2 py-1">
33
+ <!-- in audio chat mode the voice button is centered between the mode picker and an empty column -->
31
34
  <div
32
- v-if="agentStore.availableModes.length > 1"
33
- ref="modeMenu"
34
- class="relative"
35
+ class="items-center gap-1 px-2 pb-2"
36
+ :class="agentStore.isAudioChatMode ? 'grid grid-cols-[1fr_auto_1fr] pt-2' : 'flex pt-1'"
35
37
  >
36
- <button
37
- aria-label="Select mode"
38
- class="flex px-2 py-1 items-center text-sm justify-center
39
- rounded-md bg-white text-lightListTableHeadingText
40
- transition-colors duration-200 hover:bg-gray-100
41
- dark:text-darkListTableHeadingText dark:bg-gray-700 dark:hover:bg-gray-800"
42
- :class="isModeMenuOpen ? 'bg-gray-100 dark:bg-gray-700' : ''"
43
- title="Select mode"
44
- type="button"
45
- @click="toggleModeMenu"
38
+ <div
39
+ v-if="agentStore.availableModes.length > 1"
40
+ ref="modeMenu"
41
+ class="relative justify-self-start"
46
42
  >
47
- {{ agentStore.activeModeName }}
48
- <IconAngleDownOutline
49
- class="w-4 h-4 ml-1 transition-transform duration-200"
50
- :class="isModeMenuOpen ? '!rotate-180' : '!rotate-0'"
51
- />
52
- </button>
43
+ <button
44
+ aria-label="Select mode"
45
+ class="flex items-center gap-1.5 h-7 pl-2 pr-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors duration-150
46
+ text-gray-700 hover:bg-gray-100
47
+ dark:text-gray-200 dark:hover:bg-gray-700"
48
+ :class="isModeMenuOpen ? 'bg-gray-100 dark:bg-gray-700' : ''"
49
+ title="Select mode"
50
+ type="button"
51
+ @click="toggleModeMenu"
52
+ >
53
+ <IconSparklesSolid class="w-3.5 h-3.5 text-lightPrimary dark:text-darkPrimary" />
54
+ {{ agentStore.activeModeName }}
55
+ <IconChevronDown
56
+ class="w-3.5 h-3.5 transition-transform duration-200"
57
+ :class="isModeMenuOpen ? 'rotate-180' : 'rotate-0'"
58
+ />
59
+ </button>
53
60
  <Transition
54
61
  enter-active-class="transition ease-out duration-200"
55
62
  enter-from-class="opacity-0 scale-95"
@@ -58,69 +65,75 @@
58
65
  leave-from-class="opacity-100 scale-100"
59
66
  leave-to-class="opacity-0 scale-95"
60
67
  >
61
- <div
62
- v-if="isModeMenuOpen"
63
- class="absolute z-20 bottom-full left-0 mb-2 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
64
- >
68
+ <div
69
+ v-if="isModeMenuOpen"
70
+ class="absolute z-20 bottom-full left-0 mb-2 min-w-44 p-1 rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
71
+ >
72
+ <button
73
+ v-for="mode in agentStore.availableModes"
74
+ :key="mode.name"
75
+ class="flex items-center justify-between gap-3 w-full px-2.5 py-1.5 rounded-lg text-left text-sm transition-colors duration-150
76
+ text-lightInputText hover:bg-gray-100 dark:text-darkInputText dark:hover:bg-gray-700"
77
+ type="button"
78
+ @click="selectMode(mode.name)"
79
+ >
80
+ {{ mode.name }}
81
+ <IconCheck
82
+ v-if="mode.name === agentStore.activeModeName"
83
+ class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
84
+ />
85
+ </button>
86
+ </div>
87
+ </Transition>
88
+ </div>
89
+ <div
90
+ class="ml-auto flex items-center gap-1"
91
+ :class="agentStore.isAudioChatMode ? 'col-start-2' : ''"
92
+ >
93
+ <div v-if="props.meta.hasAudioAdapter" v-show="!agentStore.isEditingMessage" class="contents">
94
+ <MicrophoneButton />
95
+ </div>
96
+ <template v-if="!agentStore.isAudioChatMode">
97
+ <EditActionButtons
98
+ v-if="agentStore.isEditingMessage"
99
+ :confirm-disabled="!agentStore.trimmedUserMessage"
100
+ @cancel="cancelEdit"
101
+ @confirm="sendMessage"
102
+ />
65
103
  <button
66
- v-for="mode in agentStore.availableModes"
67
- :key="mode.name"
68
- class="block w-full px-3 py-2 text-left text-sm text-lightInputText transition-colors duration-150 hover:bg-gray-100 dark:text-darkInputText dark:hover:bg-gray-700"
69
- :class="mode.name === agentStore.activeModeName ? 'bg-gray-100 dark:bg-gray-700' : ''"
104
+ v-else-if="!agentStore.isResponseInProgress"
70
105
  type="button"
71
- @click="selectMode(mode.name)"
106
+ :class="ROUND_PRIMARY_BUTTON_CLASSES"
107
+ :disabled="!agentStore.trimmedUserMessage"
108
+ :title="$t('Send message')"
109
+ :aria-label="$t('Send message')"
110
+ @click="sendMessage"
72
111
  >
73
- {{ mode.name }}
112
+ <IconArrowUp class="w-4 h-4" />
74
113
  </button>
75
- </div>
76
- </Transition>
77
- </div>
78
- <div v-if="props.meta.hasAudioAdapter" v-show="!agentStore.isEditingMessage" class="contents">
79
- <MicrophoneButton />
80
- </div>
81
- <template v-if="!agentStore.isAudioChatMode">
82
- <template v-if="agentStore.isEditingMessage">
83
- <EditActionButtons
84
- :confirm-disabled="!agentStore.trimmedUserMessage"
85
- @cancel="cancelEdit"
86
- @confirm="sendMessage"
87
- />
88
- </template>
89
- <template v-else>
90
- <Button
91
- v-if="!agentStore.isResponseInProgress"
92
- class=" !p-0 h-7 w-7 transition-opacity duration-200"
93
- @click="sendMessage"
94
- :disabled="!agentStore.trimmedUserMessage"
95
- >
96
- <IconArrowUpOutline
97
- class="w-6 h-6
98
- text-white"
99
- />
100
- </Button>
101
- <Button
102
- v-else
103
- class="right-4 bottom-2 !p-0 h-7 w-7"
104
- @click="stopCurrentRequest"
105
- >
106
- <div
107
- class="w-2.5 h-2.5 bg-white rounded-sm"
108
- />
109
- </Button>
110
- </template>
111
- </template>
114
+ <button
115
+ v-else
116
+ type="button"
117
+ :class="ROUND_PRIMARY_BUTTON_CLASSES"
118
+ :title="$t('Stop')"
119
+ :aria-label="$t('Stop')"
120
+ @click="stopCurrentRequest"
121
+ >
122
+ <span class="w-2.5 h-2.5 rounded-sm bg-current" />
123
+ </button>
124
+ </template>
125
+ </div>
112
126
  </div>
113
127
  </div>
114
128
  </div>
115
129
  </template>
116
130
 
117
131
  <script setup lang="ts">
118
- import { IconArrowUpOutline, IconAngleDownOutline } from '@iconify-prerendered/vue-flowbite';
119
- import { useTemplateRef, onMounted, ref, onUnmounted, watch, computed } from 'vue';
132
+ import { IconArrowUp, IconCheck, IconChevronDown, IconSparklesSolid } from '@iconify-prerendered/vue-heroicons';
133
+ import { useTemplateRef, onMounted, ref, watch, computed } from 'vue';
120
134
  import { onClickOutside } from '@vueuse/core'
121
135
  import { useAgentStore } from './composables/useAgentStore';
122
136
  import { useAgentTransitions } from './composables/useAgentTransitions';
123
- import { Button } from '@/afcl';
124
137
  import { useCoreStore } from '@/stores/core';
125
138
  import { remToPx } from './utils';
126
139
  import MicrophoneButton from './speech_recognition_frontend/MicrophoneButon.vue';
@@ -143,6 +156,12 @@ const props = defineProps<{
143
156
  conversationAreaRef: any
144
157
  }>();
145
158
 
159
+ const ROUND_PRIMARY_BUTTON_CLASSES = `flex items-center justify-center w-8 h-8 shrink-0 rounded-full transition-colors duration-150
160
+ bg-lightPrimary text-lightPrimaryContrast hover:opacity-90
161
+ dark:bg-darkPrimary dark:text-darkPrimaryContrast
162
+ disabled:bg-gray-200 disabled:text-gray-400 disabled:hover:opacity-100 disabled:cursor-default
163
+ dark:disabled:bg-gray-700 dark:disabled:text-gray-500`;
164
+
146
165
  const textInput = useTemplateRef('textInput');
147
166
  const modeMenu = useTemplateRef('modeMenu');
148
167
  const agentStore = useAgentStore();
@@ -163,14 +182,17 @@ onMounted(async () => {
163
182
  agentStore.setAvailableModes(props.meta.modes, props.meta.defaultModeName);
164
183
  agentStore.setCurrentGenerationModeFromLocalStorage();
165
184
  agentStore.setEditingEnabled(props.meta.editingEnabled);
166
- agentStore.regisrerTextInput(textInput.value);
167
185
  textInput.value?.focus();
168
186
  autoResize();
169
187
  });
170
188
 
171
- watch(() => textInput.value?.value, () => {
172
- autoResize();
173
- });
189
+ // The textarea is re-created after leaving audio chat mode; keep the store pointing at the
190
+ // live element, otherwise focusTextInput() targets a detached one and does nothing.
191
+ watch(textInput, (el) => agentStore.regisrerTextInput(el), { immediate: true, flush: 'post' });
192
+
193
+ // Re-measure whenever the text changes programmatically (suggestion picked, edit restored)
194
+ // or the textarea is re-mounted after leaving audio chat mode.
195
+ watch([() => agentStore.userMessageInput, textInput], autoResize, { flush: 'post' });
174
196
 
175
197
  function autoResize() {
176
198
  const el = textInput.value
@@ -3,8 +3,8 @@
3
3
  ref="headerRef"
4
4
  class="flex items-center justify-between h-14 border-b border-gray-200 dark:border-gray-700"
5
5
  >
6
- <div
7
- class="flex items-center"
6
+ <div
7
+ class="flex items-center shrink-0"
8
8
  >
9
9
  <IconBarsOutline
10
10
  class="m-2 w-8 h-8 p-1 cursor-pointer hover:scale-110 rounded transition-colors duration-200
@@ -47,7 +47,10 @@
47
47
  @click="agentStore.setFullScreen(false)"
48
48
  />
49
49
  </div>
50
- <div class="flex items-center justify-center">
50
+ <p class="flex-1 min-w-0 truncate px-1 text-sm font-semibold text-lightNavbarText dark:text-darkNavbarText">
51
+ {{ isNewChat ? $t('New chat') : agentStore.currentSession.title }}
52
+ </p>
53
+ <div class="flex items-center justify-center shrink-0">
51
54
  <Button
52
55
  @click="agentStore.createPreSession(); agentStore.setSessionHistoryOpen(false); agentStore.focusTextInput();"
53
56
  class="!py-1 !px-2 max-w-64 mr-2"
@@ -67,12 +70,16 @@
67
70
 
68
71
 
69
72
  <script setup>
73
+ import { computed } from 'vue';
70
74
  import { IconArrowsPointingOut, IconArrowsPointingIn } from '@iconify-prerendered/vue-heroicons';
71
75
  import { IconCloseOutline, IconBarsOutline, IconCloseSidebarSolid, IconOpenSidebarSolid, IconPlusOutline } from '@iconify-prerendered/vue-flowbite';
72
76
  import { useAgentStore } from './composables/useAgentStore';
73
77
  import { useCoreStore } from '@/stores/core';
74
78
  import { Button } from '@/afcl';
79
+ import { PRE_SESSION_ID } from './composables/agentStore/constants';
75
80
 
76
81
  const agentStore = useAgentStore();
77
82
  const coreStore = useCoreStore();
83
+
84
+ const isNewChat = computed(() => !agentStore.currentSession || agentStore.currentSession.sessionId === PRE_SESSION_ID);
78
85
  </script>
@@ -6,7 +6,7 @@ export const DEFAULT_CHAT_WIDTH = 30;
6
6
  export const MAX_WIDTH = 60;
7
7
  export const MIN_WIDTH = 25;
8
8
 
9
- export const DEFAULT_TEXTAREA_PLACEHOLDER = 'Type a message...';
9
+ export const DEFAULT_TEXTAREA_PLACEHOLDER = 'Ask anything about your data…';
10
10
  export const PLACEHOLDER_TYPING_DELAY_MS = 60;
11
11
  export const PLACEHOLDER_DELETING_DELAY_MS = 35;
12
12
  export const PLACEHOLDER_HOLD_DELAY_MS = 3000;
@@ -2,6 +2,7 @@ import { DefaultChatTransport } from 'ai';
2
2
  import { ref, shallowRef, watch, type Ref } from 'vue';
3
3
  import { Chat } from '../../chat';
4
4
  import { getCurrentPageContext } from './pageContext';
5
+ import type { IApprovalDescription } from '../../types';
5
6
  // const { DefaultChatTransport } = await import('ai');
6
7
 
7
8
  type AgentImportMeta = ImportMeta & {
@@ -14,7 +15,7 @@ type CreateAgentChatManagerOptions = {
14
15
  lastMessage: Ref<string>;
15
16
  activeModeName: Ref<string | null>;
16
17
  onOpenPage: (targetPath: string) => void;
17
- onToolApprovalRequest: (sessionId: string, interrupt: unknown, approvals?: unknown) => void;
18
+ onToolApprovalRequest: (sessionId: string, approvals: IApprovalDescription[]) => void;
18
19
  };
19
20
 
20
21
  export function createAgentChatManager({
@@ -119,7 +120,7 @@ export function createAgentChatManager({
119
120
  }
120
121
 
121
122
  if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
122
- onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt, dataPart.data.approvals);
123
+ onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.approvals);
123
124
  return;
124
125
  }
125
126
 
@@ -165,7 +166,7 @@ export function createAgentChatManager({
165
166
  }
166
167
 
167
168
  if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
168
- onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt, dataPart.data.approvals);
169
+ onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.approvals);
169
170
  return;
170
171
  }
171
172
 
@@ -135,6 +135,7 @@ export function createAgentPlaceholderController({
135
135
 
136
136
  return {
137
137
  userMessagePlaceholder,
138
+ placeholderMessages,
138
139
  hasTypedMessageInPageSession,
139
140
  fetchPlaceholderMessages,
140
141
  stopPlaceholderAnimation,
@@ -1,7 +1,7 @@
1
1
  import type { ComputedRef, Ref, ShallowRef } from 'vue';
2
2
  import { callAdminForthApi } from '@/utils';
3
3
  import type { Chat } from '../../chat';
4
- import type { IAgentSession, ISessionsListItem, IPart } from '../../types';
4
+ import type { IAgentSession, IApprovalDescription, ISessionsListItem, IPart } from '../../types';
5
5
  import { PRE_SESSION_ID, STEER_PERSIST_PREFIX } from './constants';
6
6
  import { i18nInstance } from '@/i18n';
7
7
 
@@ -23,7 +23,7 @@ type CreateAgentSessionManagerOptions = {
23
23
  blockCloseOfChat: Ref<boolean>;
24
24
  adminforth: AdminforthLike;
25
25
  setCurrentChat: (sessionId: string) => void;
26
- restorePendingToolApproval: (sessionId: string, messages: string[]) => void;
26
+ restorePendingToolApproval: (sessionId: string, approvals: IApprovalDescription[]) => void;
27
27
  };
28
28
 
29
29
  export function createAgentSessionManager({
@@ -1,5 +1,5 @@
1
1
  import { defineStore } from 'pinia';
2
- import { IAgentSession, ISessionsListItem } from '../types';
2
+ import { IAgentSession, IApprovalDescription, ISessionsListItem } from '../types';
3
3
  import { ref, nextTick, computed, watch, onMounted } from 'vue';
4
4
  import { useRouter } from 'vue-router';
5
5
  import { useAdminforth } from '@/adminforth';
@@ -69,10 +69,11 @@ export const useAgentStore = defineStore('agent', () => {
69
69
  lastMessage,
70
70
  activeModeName,
71
71
  onOpenPage: openAgentPage,
72
- onToolApprovalRequest: addToolApprovalMessage,
72
+ onToolApprovalRequest: addToolApprovalPart,
73
73
  });
74
74
  const {
75
75
  userMessagePlaceholder,
76
+ placeholderMessages,
76
77
  hasTypedMessageInPageSession,
77
78
  fetchPlaceholderMessages,
78
79
  stopPlaceholderAnimation,
@@ -108,28 +109,13 @@ export const useAgentStore = defineStore('agent', () => {
108
109
  isAudioChatMode.value = isAudioChat;
109
110
  }
110
111
 
111
- function getToolApprovalMessages(interrupt: unknown): string[] {
112
- const interrupts = Array.isArray(interrupt) ? interrupt : [interrupt];
113
-
114
- return interrupts.flatMap((item: any) => {
115
- const value = item?.value ?? item;
116
- const actionRequests = Array.isArray(value?.actionRequests) ? value.actionRequests : [];
117
-
118
- return actionRequests.map((actionRequest: any) => (
119
- typeof actionRequest?.description === 'string'
120
- ? actionRequest.description
121
- : String(actionRequest?.name ?? 'Tool execution pending approval')
122
- ));
123
- });
124
- }
125
-
126
- function addToolApprovalPart(sessionId: string, messages: string[]) {
112
+ function addToolApprovalPart(sessionId: string, approvals: IApprovalDescription[]) {
127
113
  const approvalPart = {
128
114
  type: 'data-tool-approval' as const,
129
115
  data: {
130
116
  sessionId,
131
117
  status: 'pending' as const,
132
- messages,
118
+ approvals,
133
119
  },
134
120
  };
135
121
  const lastChatMessage = currentChat.value?.lastMessage;
@@ -146,16 +132,6 @@ export const useAgentStore = defineStore('agent', () => {
146
132
  });
147
133
  }
148
134
 
149
- function addToolApprovalMessage(sessionId: string, interrupt: unknown, approvals?: unknown) {
150
- // The backend describes each pending tool call in plain language; its raw
151
- // descriptions are only the fallback when it could not.
152
- const formatted = Array.isArray(approvals)
153
- ? approvals.filter((line): line is string => typeof line === 'string' && Boolean(line))
154
- : [];
155
-
156
- addToolApprovalPart(sessionId, formatted.length ? formatted : getToolApprovalMessages(interrupt));
157
- }
158
-
159
135
  async function submitToolApproval(sessionId: string, decision: 'approve' | 'reject') {
160
136
  const message = (currentChat.value?.messages ?? [])
161
137
  .findLast(candidate => candidate.role === 'assistant' && (candidate.parts ?? []).some((part: any) => {
@@ -628,6 +604,7 @@ export const useAgentStore = defineStore('agent', () => {
628
604
  submitToolApproval,
629
605
  userMessageInput,
630
606
  userMessagePlaceholder,
607
+ placeholderMessages,
631
608
  chatMessages: computed(() => currentChat.value?.messages || []),
632
609
  trimmedUserMessage,
633
610
  isResponseInProgress,
@@ -45,10 +45,32 @@
45
45
  <!-- absolute, so it doesn't resize the scroll content when the container resizes (see the spacer comment) -->
46
46
  <div
47
47
  v-if="props.messages.length === 0"
48
- class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 tracking-widest text-xl font-medium"
48
+ class="absolute inset-0 flex flex-col items-center justify-center px-7 pb-10 text-center"
49
49
  >
50
- <p>{{ $t('Start the conversation') }}</p>
51
- <p class="tracking-normal text-base text">{{ $t('Give any input to begin') }}</p>
50
+ <div class="flex items-center justify-center w-11 h-11 rounded-xl bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/15 dark:text-darkPrimary">
51
+ <IconSparkles class="w-6 h-6" />
52
+ </div>
53
+ <h2 class="mt-4 text-xl font-semibold text-lightNavbarText dark:text-darkNavbarText">
54
+ {{ $t('How can I help?') }}
55
+ </h2>
56
+ <p class="mt-1.5 max-w-xs text-sm leading-relaxed text-gray-600 dark:text-gray-400">
57
+ {{ $t('Ask about your data, build charts or update records.') }}
58
+ </p>
59
+ <div v-if="suggestions.length" class="mt-7 w-full max-w-sm flex flex-col gap-2 text-left">
60
+ <p class="pl-0.5 text-xs font-medium text-gray-500 dark:text-gray-400">{{ $t('Try asking') }}</p>
61
+ <button
62
+ v-for="suggestion in suggestions"
63
+ :key="suggestion"
64
+ type="button"
65
+ class="group flex items-center gap-2.5 w-full px-3 py-2.5 rounded-xl border text-sm text-left transition-colors duration-150
66
+ border-gray-200 text-lightNavbarText hover:border-gray-300 hover:bg-gray-50
67
+ dark:border-gray-700 dark:text-darkNavbarText dark:hover:border-gray-600 dark:hover:bg-gray-800"
68
+ @click="applySuggestion(suggestion)"
69
+ >
70
+ <span class="flex-1 min-w-0">{{ suggestion }}</span>
71
+ <IconArrowRight class="w-4 h-4 shrink-0 text-gray-400 transition-transform duration-150 group-hover:translate-x-0.5" />
72
+ </button>
73
+ </div>
52
74
  </div>
53
75
  </CustomAutoScrollContainer>
54
76
  <Transition
@@ -89,6 +111,7 @@
89
111
  import type { IMessage } from '../types';
90
112
  import { useTemplateRef, ref, onMounted, onUnmounted, watch, nextTick, defineAsyncComponent, computed } from 'vue';
91
113
  import { IconArrowDownOutline } from '@iconify-prerendered/vue-flowbite';
114
+ import { IconArrowRight, IconSparkles } from '@iconify-prerendered/vue-heroicons';
92
115
  import SessionsHistory from '../SessionsHistory.vue';
93
116
  import { useAgentStore } from '../composables/useAgentStore';
94
117
  import { useAgentTransitions } from '../composables/useAgentTransitions';
@@ -132,6 +155,7 @@ const lastTurnHeight = ref(0);
132
155
  const MASK_HEIGHT = 20;
133
156
  const EMPTY_MESSAGE_HEIGHT = 18;
134
157
  const THRESHOLD_TO_SHOW_BUTTON = 10;
158
+ const MAX_SUGGESTIONS = 3;
135
159
  let messageResizeObserver: ResizeObserver | null = null;
136
160
  let observedLastUserMessageElement: HTMLElement | null = null;
137
161
  let observedLastAgentMessageElement: HTMLElement | null = null;
@@ -143,6 +167,14 @@ const showAnimationInScrollToBottomButton = computed(() => {
143
167
  return agentStore.isResponseInProgress && showScrollToBottomButton.value;
144
168
  });
145
169
 
170
+ // The configured placeholder examples double as clickable starters on an empty chat.
171
+ const suggestions = computed(() => agentStore.placeholderMessages.slice(0, MAX_SUGGESTIONS));
172
+
173
+ function applySuggestion(suggestion: string) {
174
+ agentStore.userMessageInput = suggestion;
175
+ agentStore.focusTextInput();
176
+ }
177
+
146
178
  onMounted(async () => {
147
179
  messageResizeObserver = new ResizeObserver(() => {
148
180
  scheduleSpacerHeightUpdate();
@@ -26,20 +26,20 @@
26
26
  <button
27
27
  v-if="canCopy"
28
28
  type="button"
29
- class="flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-lightPrimary dark:hover:text-darkPrimary opacity-60 group-hover/msg:opacity-100 transition-opacity duration-150 hover:scale-110"
29
+ :class="ACTION_BUTTON_CLASSES"
30
30
  :title="$t('Copy message')"
31
31
  @click="copyMessageToClipboard"
32
32
  >
33
- <IconFileCopySolid class="w-5 h-5" />
33
+ <IconFileCopyOutline class="w-4 h-4" />
34
34
  </button>
35
35
  <button
36
36
  v-if="canEdit"
37
37
  type="button"
38
- class="flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-lightPrimary dark:hover:text-darkPrimary opacity-60 group-hover/msg:opacity-100 transition-opacity duration-150 hover:scale-110"
38
+ :class="ACTION_BUTTON_CLASSES"
39
39
  :title="$t('Edit message')"
40
40
  @click="agentStore.startEditMessage(message)"
41
41
  >
42
- <IconPenSolid class="w-5 h-5" />
42
+ <IconPenOutline class="w-4 h-4" />
43
43
  </button>
44
44
  </div>
45
45
  </Transition>
@@ -83,7 +83,7 @@
83
83
 
84
84
  <script setup lang="ts">
85
85
  import { computed, ref } from 'vue';
86
- import { IconPenSolid, IconFileCopySolid } from '@iconify-prerendered/vue-flowbite';
86
+ import { IconPenOutline, IconFileCopyOutline } from '@iconify-prerendered/vue-flowbite';
87
87
  import TextRenderer from './TextRenderer.vue';
88
88
  import type { IMessage } from '../types';
89
89
  import { getMessageParts } from '../utils';
@@ -99,6 +99,10 @@
99
99
  isLastMessageInChat: boolean
100
100
  }>();
101
101
 
102
+ const ACTION_BUTTON_CLASSES = `flex items-center justify-center w-7 h-7 rounded-md transition-colors duration-150
103
+ text-gray-500 hover:bg-gray-100 hover:text-gray-800
104
+ dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200`;
105
+
102
106
  const agentStore = useAgentStore();
103
107
  const { alert } = useAdminforth();
104
108
  const showEditButton = ref(false);