Skip to content

Commit b073998

Browse files
authored
Merge pull request #15 from devforth/steer
Steer
2 parents 07346f5 + 6a8803c commit b073998

24 files changed

Lines changed: 793 additions & 58 deletions

application/runTurnUseCase.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { buildAgentTurnSystemPrompt } from "../domain/systemPrompt.js";
55
import { getErrorMessage, isAbortError } from "../shared/errors.js";
66
import type { PreviousUserMessage } from "../domain/languageDetect.js";
77
import type { AgentSessionStore } from "../persistence/sessionStore.js";
8+
import type { SteerBuffer } from "../domain/steerBuffer.js";
89
import type { PluginOptions } from "../types.js";
910
import type { LlmPort } from "./ports.js";
1011
import type {
@@ -103,6 +104,8 @@ export function buildResumeValue(input: {
103104
export type RunTurnUseCaseDeps = {
104105
llm: LlmPort;
105106
sessions: AgentSessionStore;
107+
/** Per-session buffer of mid-turn steering instructions; cleared when a turn ends unconsumed. */
108+
steerBuffer: SteerBuffer;
106109
modes: PluginOptions["modes"];
107110
getAdminforth: () => IAdminForth;
108111
getAgentSystemPrompt: () => Promise<string>;
@@ -314,6 +317,12 @@ export class RunTurnUseCase {
314317
await textBuffer.flush(emit);
315318
return { text: fullResponse };
316319
} finally {
320+
// When the turn is interrupted for HITL approval it will resume, so any steers
321+
// buffered meanwhile must survive to be applied on resume. Otherwise the turn is
322+
// done and leftover (unconsumed) steers must not leak into the next turn.
323+
if (!interrupted) {
324+
this.deps.steerBuffer.clear(prepared.sessionId);
325+
}
317326
if (!this.deps.hasPersistentCheckpointer && !interrupted) {
318327
this.pendingInterrupts.delete(prepared.sessionId);
319328
}

custom/ChatFooter.vue

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,30 @@
77
transition: `transform ${agentTransitions.TRANSITION_DURATION}ms ease-in-out`
88
}"
99
>
10-
<div
11-
class="w-full border rounded-lg pb-8"
12-
:class="agentStore.isAudioChatMode ? 'border-none mt-8' : 'border dark:bg-gray-700'"
13-
>
10+
<SteerQueue />
11+
<!-- <SteerDebugPanel /> -->
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'"
15+
>
1416
<textarea
1517
v-if="!agentStore.isAudioChatMode"
1618
v-model="agentStore.userMessageInput"
1719
ref="textInput"
20+
rows="1"
1821
@input="autoResize"
1922
:class="[
20-
'min-h-12 px-4 pt-4 rounded-xl 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',
23+
'h-8 px-4 py-3 border-b 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',
2124
{ '!text-base': coreStore.isIos }
2225
]"
23-
:placeholder="agentStore.hasPendingToolApproval ? 'Approve or reject the pending action to continue' : agentStore.userMessagePlaceholder"
24-
:disabled="agentStore.isMessageInputBlocked"
26+
:placeholder="placeholderText"
2527
@keydown.enter.exact.prevent="sendMessage"
2628
/>
29+
<div class="flex items-center justify-between px-2 py-1">
2730
<div
2831
v-if="agentStore.availableModes.length > 1"
2932
ref="modeMenu"
30-
class="absolute bottom-2 left-4"
33+
class="relative"
3134
>
3235
<button
3336
aria-label="Select mode"
@@ -46,62 +49,73 @@
4649
:class="isModeMenuOpen ? '!rotate-180' : '!rotate-0'"
4750
/>
4851
</button>
49-
50-
<div
51-
v-if="isModeMenuOpen"
52-
class="absolute 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"
53-
>
54-
<button
55-
v-for="mode in agentStore.availableModes"
56-
:key="mode.name"
57-
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"
58-
:class="mode.name === agentStore.activeModeName ? 'bg-gray-100 dark:bg-gray-700' : ''"
59-
type="button"
60-
@click="selectMode(mode.name)"
52+
<Transition
53+
enter-active-class="transition ease-out duration-200"
54+
enter-from-class="opacity-0 scale-95"
55+
enter-to-class="opacity-100 scale-100"
56+
leave-active-class="transition ease-in duration-150"
57+
leave-from-class="opacity-100 scale-100"
58+
leave-to-class="opacity-0 scale-95"
59+
>
60+
<div
61+
v-if="isModeMenuOpen"
62+
class="absolute 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"
6163
>
62-
{{ mode.name }}
63-
</button>
64-
</div>
64+
<button
65+
v-for="mode in agentStore.availableModes"
66+
:key="mode.name"
67+
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"
68+
:class="mode.name === agentStore.activeModeName ? 'bg-gray-100 dark:bg-gray-700' : ''"
69+
type="button"
70+
@click="selectMode(mode.name)"
71+
>
72+
{{ mode.name }}
73+
</button>
74+
</div>
75+
</Transition>
6576
</div>
6677
<MicrophoneButton
6778
v-if="props.meta.hasAudioAdapter"
6879
/>
6980
<template v-if="!agentStore.isAudioChatMode">
7081
<Button
7182
v-if="!agentStore.isResponseInProgress"
72-
class="absolute right-4 bottom-2 !p-0 h-9 w-9 transition-opacity duration-200"
83+
class=" !p-0 h-7 w-7 transition-opacity duration-200"
7384
@click="sendMessage"
74-
:disabled="!agentStore.trimmedUserMessage || agentStore.isMessageInputBlocked"
85+
:disabled="!agentStore.trimmedUserMessage"
7586
>
7687
<IconArrowUpOutline
77-
class="w-8 h-8 p-1
88+
class="w-6 h-6
7889
text-white"
7990
/>
8091
</Button>
8192
<Button
8293
v-else
83-
class="absolute right-4 bottom-2 !p-0 h-9 w-9"
94+
class="right-4 bottom-2 !p-0 h-7 w-7"
8495
@click="stopCurrentRequest"
8596
>
8697
<div
87-
class="w-3 h-3 bg-white rounded-sm"
98+
class="w-2.5 h-2.5 bg-white rounded-sm"
8899
/>
89100
</Button>
90101
</template>
102+
</div>
91103
</div>
92104
</div>
93105
</template>
94106

95107
<script setup lang="ts">
96108
import { IconArrowUpOutline, IconAngleDownOutline } from '@iconify-prerendered/vue-flowbite';
97-
import { useTemplateRef, onMounted, ref, onUnmounted } from 'vue';
109+
import { useTemplateRef, onMounted, ref, onUnmounted, watch, computed } from 'vue';
98110
import { onClickOutside } from '@vueuse/core'
99111
import { useAgentStore } from './composables/useAgentStore';
100112
import { useAgentTransitions } from './composables/useAgentTransitions';
101113
import { Button } from '@/afcl';
102114
import { useCoreStore } from '@/stores/core';
103115
import { remToPx } from './utils';
104116
import MicrophoneButton from './speech_recognition_frontend/MicrophoneButon.vue';
117+
import SteerQueue from './SteerQueue.vue';
118+
import SteerDebugPanel from './SteerDebugPanel.vue';
105119
106120
const props = defineProps<{
107121
meta: {
@@ -124,13 +138,25 @@ const agentTransitions = useAgentTransitions();
124138
const coreStore = useCoreStore();
125139
const isModeMenuOpen = ref(false);
126140
141+
const placeholderText = computed(() => {
142+
if (agentStore.isTurnActive) {
143+
return 'Queue a follow-up — send after, or steer it…';
144+
}
145+
return agentStore.userMessagePlaceholder;
146+
});
147+
127148
onClickOutside(modeMenu as any, () => { isModeMenuOpen.value = false; });
128149
129150
onMounted(async () => {
130151
agentStore.setAvailableModes(props.meta.modes, props.meta.defaultModeName);
131152
agentStore.setCurrentGenerationModeFromLocalStorage();
132153
agentStore.regisrerTextInput(textInput.value);
133154
textInput.value?.focus();
155+
autoResize();
156+
});
157+
158+
watch(() => textInput.value?.value, () => {
159+
autoResize();
134160
});
135161
136162
function autoResize() {
@@ -161,7 +187,9 @@ function selectMode(modeName: string) {
161187
async function sendMessage() {
162188
if (agentStore.isAudioChatMode) return;
163189
isModeMenuOpen.value = false;
164-
await agentStore.sendMessage();
190+
// Routes to a steer (folded into the running turn) while generating, or a normal
191+
// new-turn send when idle.
192+
await agentStore.submitUserMessage();
165193
autoResize();
166194
props.conversationAreaRef?.handleSendMessage();
167195
}

custom/SteerDebugPanel.vue

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<!--
2+
TEMPORARY debug helper: enqueues a test steer message into the buffer WITHOUT sending
3+
or steering it, so the SteerQueue enter/leave animation can be exercised without a live
4+
generating turn. Remove before shipping.
5+
-->
6+
<template>
7+
<div
8+
class="fixed bottom-[400px] left-4 z-[9999] flex items-center gap-2 rounded-lg border border-dashed border-amber-400 bg-amber-50/95 px-2.5 py-2 shadow-lg backdrop-blur dark:border-amber-500 dark:bg-amber-950/95"
9+
>
10+
<span class="text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">
11+
Steer&nbsp;debug
12+
</span>
13+
<input
14+
v-model="debugText"
15+
type="text"
16+
placeholder="Test steer text…"
17+
class="w-44 rounded-md border border-amber-300 bg-white px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-amber-400 dark:border-amber-600 dark:bg-gray-800 dark:text-gray-100"
18+
@keydown.enter.prevent="addTestSteer"
19+
/>
20+
<button
21+
type="button"
22+
class="rounded-md bg-amber-500 px-2.5 py-1 text-xs font-semibold text-white transition-colors hover:bg-amber-600 active:bg-amber-700"
23+
@click="addTestSteer"
24+
>
25+
Add steer
26+
</button>
27+
</div>
28+
</template>
29+
30+
<script setup lang="ts">
31+
import { ref } from 'vue';
32+
import { useAgentStore } from './composables/useAgentStore';
33+
34+
const agentStore = useAgentStore();
35+
const debugText = ref('');
36+
let counter = 0;
37+
38+
function addTestSteer() {
39+
counter += 1;
40+
const text = debugText.value.trim() || `Test steer #${counter}`;
41+
// enqueue() only pushes onto the client-side buffer — no /agent/steer, no send.
42+
agentStore.enqueueSteerMessage(text);
43+
debugText.value = '';
44+
}
45+
</script>

custom/SteerQueue.vue

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<template>
2+
<div
3+
v-if="agentStore.steerQueue.length"
4+
class="absolute bottom-full left-0 w-full mb-2 flex flex-col gap-1.5 px-2 z-20"
5+
>
6+
<TransitionGroup
7+
appear
8+
enter-active-class="transition ease-out duration-150"
9+
enter-from-class="opacity-0 translate-y-1"
10+
enter-to-class="opacity-100 translate-y-0"
11+
leave-active-class="transition ease-in duration-100"
12+
leave-from-class="opacity-100 translate-y-0"
13+
leave-to-class="opacity-0 translate-y-1"
14+
>
15+
<div
16+
v-for="item in agentStore.steerQueue"
17+
:key="item.id"
18+
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 shadow-sm dark:border-gray-600 dark:bg-gray-800"
19+
>
20+
<span
21+
class="flex-1 min-w-0 truncate text-sm text-lightInputText dark:text-darkInputText"
22+
:title="item.text"
23+
>
24+
{{ item.text }}
25+
</span>
26+
<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500">
27+
{{ $t('queued') }}
28+
</span>
29+
<button
30+
type="button"
31+
class="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-lightPrimary transition-colors hover:bg-lightPrimary/10 dark:text-darkPrimary dark:hover:bg-darkPrimary/10"
32+
:title="$t('Steer into the current response')"
33+
@click="agentStore.steerQueuedMessage(item.id)"
34+
>
35+
{{ $t('Steer') }}
36+
</button>
37+
<button
38+
type="button"
39+
class="shrink-0 flex h-6 w-6 items-center justify-center rounded-md text-lg leading-none text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
40+
:title="$t('Remove from queue')"
41+
@click="agentStore.removeQueuedMessage(item.id)"
42+
>
43+
&times;
44+
</button>
45+
</div>
46+
</TransitionGroup>
47+
</div>
48+
</template>
49+
50+
<script setup lang="ts">
51+
import { useAgentStore } from './composables/useAgentStore';
52+
53+
const agentStore = useAgentStore();
54+
</script>

custom/composables/agentStore/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const PLACEHOLDER_TYPING_DELAY_MS = 60;
1111
export const PLACEHOLDER_DELETING_DELAY_MS = 35;
1212
export const PLACEHOLDER_HOLD_DELAY_MS = 3000;
1313
export const PRE_SESSION_ID = 'pre-session';
14+
export const STEER_PERSIST_PREFIX = '__adminforth_steer__:';
1415

1516
export enum RESERVED_SYSTEM_MESSAGE_CONTENT {
1617
START_AUDIO_CHAT = 'START_AUDIO_CHAT',

custom/composables/agentStore/useAgentSessions.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ComputedRef, Ref, ShallowRef } from 'vue';
22
import { callAdminForthApi } from '@/utils';
33
import type { Chat } from '../../chat';
44
import type { IAgentSession, ISessionsListItem, IPart } from '../../types';
5-
import { PRE_SESSION_ID } from './constants';
5+
import { PRE_SESSION_ID, STEER_PERSIST_PREFIX } from './constants';
66
import { i18nInstance } from '@/i18n';
77

88
type AdminforthLike = {
@@ -50,14 +50,36 @@ export function createAgentSessionManager({
5050

5151
function saveCurrentSessionInCache() {
5252
if (currentSession.value) {
53-
currentSession.value.messages = currentChat.value?.messages.map((m: any) => ({
54-
role: m.role,
55-
text: m.parts.map((p: IPart) => p.type === 'text' ? p.text : '').join(''),
56-
})) || [];
53+
currentSession.value.messages = currentChat.value?.messages.map((m: any) => {
54+
const text = m.parts.map((p: IPart) => p.type === 'text' ? p.text : '').join('');
55+
if (m.role === 'user' && m.metadata?.steer) {
56+
return { role: 'user', text: `${STEER_PERSIST_PREFIX}${text}` };
57+
}
58+
return { role: m.role, text };
59+
}) || [];
5760
sessions.value[currentSession.value.sessionId] = currentSession.value;
5861
}
5962
}
6063

64+
function mapStoredMessage(m: any): any[] {
65+
if (m.role === 'user' && typeof m.text === 'string' && m.text.includes(STEER_PERSIST_PREFIX)) {
66+
const [prompt, ...steers] = m.text.split(STEER_PERSIST_PREFIX);
67+
const mapped: any[] = [];
68+
if (prompt) {
69+
mapped.push({ role: 'user', parts: [{ type: 'text', text: prompt, state: 'done' }] });
70+
}
71+
for (const steer of steers) {
72+
mapped.push({
73+
role: 'user',
74+
metadata: { steer: true },
75+
parts: [{ type: 'text', text: steer, state: 'done' }],
76+
});
77+
}
78+
return mapped;
79+
}
80+
return [{ role: m.role, parts: [{ type: 'text', text: m.text, state: 'done' }] }];
81+
}
82+
6183
async function fetchSession(sessionId: string) {
6284
try {
6385
const res = await callAdminForthApi({
@@ -85,14 +107,7 @@ export function createAgentSessionManager({
85107
currentSession.value = sessions.value[sessionId];
86108
setCurrentChat(sessionId);
87109
if (currentChat.value.messages.length === 0) {
88-
currentChat.value.messages = currentSession.value?.messages.map((m: any) => ({
89-
role: m.role,
90-
parts:[{
91-
type: 'text',
92-
text: m.text,
93-
state: 'done',
94-
}]
95-
}));
110+
currentChat.value.messages = currentSession.value?.messages.flatMap(mapStoredMessage);
96111
}
97112
}
98113

@@ -130,8 +145,10 @@ export function createAgentSessionManager({
130145
}
131146
}
132147

133-
async function sendMessage() {
134-
const message = trimmedUserMessage.value;
148+
async function sendMessage(explicitMessage?: string) {
149+
// `explicitMessage` is used to drain a buffered (queued) message; without it we send
150+
// whatever is currently typed in the input.
151+
const message = (explicitMessage ?? trimmedUserMessage.value).trim();
135152
if (!message || isMessageInputBlocked.value) {
136153
return;
137154
}
@@ -147,7 +164,9 @@ export function createAgentSessionManager({
147164
currentChat.value?.sendMessage({
148165
text: message,
149166
});
150-
userMessageInput.value = '';
167+
if (explicitMessage === undefined) {
168+
userMessageInput.value = '';
169+
}
151170
}
152171

153172
async function createPreSession() {

0 commit comments

Comments
 (0)