mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update files
This commit is contained in:
@@ -31,6 +31,8 @@ import { encodeImages } from "@/utils/encode-images";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -512,10 +514,23 @@ export function AgentInputArea({
|
||||
},
|
||||
[setUserInput]
|
||||
);
|
||||
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
|
||||
|
||||
// Handle keyboard navigation for command autocomplete
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: { key: string; preventDefault: () => void }) => {
|
||||
if (
|
||||
event.key === "Escape" &&
|
||||
isAgentRunning &&
|
||||
!hasSendableContent &&
|
||||
!isCancellingAgent &&
|
||||
isConnected
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleCancelAgent();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!showCommandAutocomplete || filteredCommands.length === 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -558,54 +573,72 @@ export function AgentInputArea({
|
||||
filteredCommands,
|
||||
commandSelectedIndex,
|
||||
handleCommandSelect,
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
setUserInput,
|
||||
]
|
||||
);
|
||||
|
||||
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
|
||||
|
||||
const cancelButton = isAgentRunning && !hasSendableContent ? (
|
||||
<Pressable
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!isConnected || isCancellingAgent}
|
||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton as any,
|
||||
(!isConnected || isCancellingAgent
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={18} color="white" fill="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!isConnected || isCancellingAgent}
|
||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton as any,
|
||||
(!isConnected || isCancellingAgent
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={18} color="white" fill="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
<Shortcut keys={["Esc"]} style={styles.tooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
|
||||
const rightContent = (
|
||||
<View style={styles.rightControls}>
|
||||
{!isVoiceModeForAgent ? (
|
||||
<Pressable
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||
accessibilityLabel="Enable realtime voice mode"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.realtimeVoiceButton as any,
|
||||
(!isConnected || voice?.isVoiceSwitching
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{voice?.isVoiceSwitching ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<AudioLines size={18} color={theme.colors.foreground} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||
accessibilityLabel="Enable Voice mode"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.realtimeVoiceButton as any,
|
||||
(!isConnected || voice?.isVoiceSwitching
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{voice?.isVoiceSwitching ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<AudioLines size={18} color={theme.colors.foreground} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||
<Shortcut keys={["mod", "shift", "D"]} style={styles.tooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{cancelButton}
|
||||
</View>
|
||||
@@ -647,7 +680,7 @@ export function AgentInputArea({
|
||||
onPress={() => handleSendQueuedNow(item.id)}
|
||||
style={[styles.queueActionButton, styles.queueSendButton]}
|
||||
>
|
||||
<ArrowUp size={14} color={theme.colors.background} />
|
||||
<ArrowUp size={14} color="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -752,6 +785,19 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.palette.green[600],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
tooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
@@ -765,7 +811,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
@@ -790,7 +836,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
queueSendButton: {
|
||||
backgroundColor: theme.colors.palette.blue[600],
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
sendErrorText: {
|
||||
color: theme.colors.palette.red[500],
|
||||
|
||||
@@ -377,14 +377,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
modeBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: "transparent",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
modeBadgeHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
modeBadgePressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
@@ -31,6 +32,8 @@ import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
|
||||
export interface ImageAttachment {
|
||||
uri: string;
|
||||
@@ -217,6 +220,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const socketConnected = client?.isConnected ?? false;
|
||||
return socketConnected;
|
||||
}, [client]);
|
||||
const isConnected = client?.isConnected ?? false;
|
||||
|
||||
const {
|
||||
isRecording: isDictating,
|
||||
@@ -263,9 +267,39 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
sendAfterTranscriptRef.current = false;
|
||||
}, [dictationStatus, isDictating, isDictationProcessing]);
|
||||
|
||||
// Cmd+D to start/submit dictation, Escape to cancel
|
||||
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) return;
|
||||
const toggleRealtimeVoice = () => {
|
||||
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceSwitching) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (isAgentRunning && client) {
|
||||
tasks.push(client.cancelAgent(voiceAgentId));
|
||||
}
|
||||
tasks.push(voice.stopVoice());
|
||||
void Promise.allSettled(tasks).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.error(
|
||||
"[MessageInput] Failed to stop realtime voice",
|
||||
result.reason
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||
});
|
||||
};
|
||||
|
||||
const resolveNativeInput = (): unknown => {
|
||||
const current = textInputRef.current as any;
|
||||
if (!current) return null;
|
||||
@@ -280,12 +314,32 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) {
|
||||
return;
|
||||
}
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
const isKeyD = event.code === "KeyD" || event.key.toLowerCase() === "d";
|
||||
|
||||
if (isMod && event.shiftKey && isKeyD && !event.repeat) {
|
||||
event.preventDefault();
|
||||
toggleRealtimeVoice();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isRealtimeVoiceForCurrentAgent &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Space" || event.key === " ") &&
|
||||
!event.repeat
|
||||
) {
|
||||
event.preventDefault();
|
||||
voice?.toggleMute();
|
||||
return;
|
||||
}
|
||||
|
||||
const dictating = isDictatingRef.current;
|
||||
// Cmd+D: start dictation or submit if already dictating
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
(event.code === "KeyD" || event.key.toLowerCase() === "d")
|
||||
) {
|
||||
if (isMod && isKeyD) {
|
||||
event.preventDefault();
|
||||
if (dictating) {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
@@ -303,7 +357,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [cancelDictation, confirmDictation, isScreenFocused, startDictation]);
|
||||
}, [
|
||||
cancelDictation,
|
||||
client,
|
||||
confirmDictation,
|
||||
disabled,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isScreenFocused,
|
||||
startDictation,
|
||||
voiceAgentId,
|
||||
voiceServerId,
|
||||
voice,
|
||||
]);
|
||||
|
||||
// Animate overlay
|
||||
useEffect(() => {
|
||||
@@ -381,6 +448,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
});
|
||||
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]);
|
||||
|
||||
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
|
||||
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceSwitching) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
|
||||
void handleStopRealtimeVoice();
|
||||
return;
|
||||
}
|
||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||
});
|
||||
}, [
|
||||
disabled,
|
||||
handleStopRealtimeVoice,
|
||||
isConnected,
|
||||
voice,
|
||||
voiceAgentId,
|
||||
voiceServerId,
|
||||
]);
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed && images.length === 0) return;
|
||||
@@ -514,6 +604,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+D or Ctrl+Shift+D: toggle realtime voice mode
|
||||
if ((metaKey || ctrlKey) && shiftKey && key === "d") {
|
||||
event.preventDefault();
|
||||
handleToggleRealtimeVoiceShortcut();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
|
||||
if ((metaKey || ctrlKey) && key === "d") {
|
||||
event.preventDefault();
|
||||
@@ -533,6 +630,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isRealtimeVoiceForCurrentAgent &&
|
||||
!metaKey &&
|
||||
!ctrlKey &&
|
||||
!shiftKey &&
|
||||
event.nativeEvent.key === " "
|
||||
) {
|
||||
event.preventDefault();
|
||||
voice?.toggleMute();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
|
||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||
@@ -555,7 +664,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const hasImages = images.length > 0;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||
const isConnected = client?.isConnected ?? false;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -636,90 +744,130 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
{/* Left: attachment button + leftContent slot */}
|
||||
<View style={styles.leftButtonGroup}>
|
||||
{onPickImages && (
|
||||
<Pressable
|
||||
onPress={onPickImages}
|
||||
disabled={!isConnected || disabled}
|
||||
style={[
|
||||
styles.attachButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={20} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onPickImages}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.attachButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={20} color={theme.colors.foreground} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Attach images</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{leftContent}
|
||||
</View>
|
||||
|
||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||
<View style={styles.rightButtonGroup}>
|
||||
<Pressable
|
||||
onPress={handleVoicePress}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isRealtimeVoiceForCurrentAgent
|
||||
? voice?.isMuted
|
||||
? "Unmute realtime voice"
|
||||
: "Mute realtime voice"
|
||||
: isDictating
|
||||
? "Stop dictation"
|
||||
: "Start dictation"
|
||||
}
|
||||
style={[
|
||||
styles.voiceButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
]}
|
||||
>
|
||||
{isDictating ? (
|
||||
<Square size={14} color="white" fill="white" />
|
||||
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
||||
<MicOff size={20} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Mic size={20} color={theme.colors.foreground} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleVoicePress}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isRealtimeVoiceForCurrentAgent
|
||||
? voice?.isMuted
|
||||
? "Unmute Voice mode"
|
||||
: "Mute Voice mode"
|
||||
: isDictating
|
||||
? "Stop dictation"
|
||||
: "Start dictation"
|
||||
}
|
||||
style={[
|
||||
styles.voiceButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
]}
|
||||
>
|
||||
{isDictating ? (
|
||||
<Square size={14} color="white" fill="white" />
|
||||
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
||||
<MicOff size={20} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Mic size={20} color={theme.colors.foreground} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>
|
||||
{isRealtimeVoiceForCurrentAgent
|
||||
? voice?.isMuted
|
||||
? "Unmute voice"
|
||||
: "Mute voice"
|
||||
: "Dictation"}
|
||||
</Text>
|
||||
<Shortcut
|
||||
keys={isRealtimeVoiceForCurrentAgent ? ["Space"] : ["mod", "D"]}
|
||||
style={styles.tooltipShortcut}
|
||||
/>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{rightContent}
|
||||
{shouldShowSendButton && isAgentRunning && onQueue && (
|
||||
<Pressable
|
||||
onPress={handleQueueMessage}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Queue message"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.queueButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Plus size={20} color="white" />
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleQueueMessage}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Queue message"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.queueButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Plus size={20} color="white" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Queue</Text>
|
||||
<Shortcut keys={["mod", "Enter"]} style={styles.tooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shouldShowSendButton && (
|
||||
<Pressable
|
||||
onPress={handleSendMessage}
|
||||
disabled={
|
||||
!isConnected ||
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled
|
||||
}
|
||||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.sendButton,
|
||||
(!isConnected ||
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleSendMessage}
|
||||
disabled={
|
||||
!isConnected ||
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled) &&
|
||||
styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
{isSubmitLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<ArrowUp size={20} color="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
disabled
|
||||
}
|
||||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.sendButton,
|
||||
(!isConnected ||
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled) &&
|
||||
styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
{isSubmitLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<ArrowUp size={20} color="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Send</Text>
|
||||
<Shortcut keys={["Enter"]} style={styles.tooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -775,7 +923,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
inputWrapper: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
@@ -898,6 +1046,19 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
tooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
@@ -463,9 +463,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
content: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderWidth: theme.borderWidth[2],
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
|
||||
@@ -68,6 +68,11 @@ import type {
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||
import {
|
||||
buildVoiceAgentMcpServerConfig,
|
||||
buildVoiceModeSystemPrompt,
|
||||
stripVoiceModeSystemPrompt,
|
||||
} from "./voice-config.js";
|
||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
||||
import {
|
||||
listDirectoryEntries,
|
||||
@@ -130,18 +135,6 @@ const MAX_AGENTS_PER_PROJECT = 5;
|
||||
* Uses Claude Haiku for speed and cost efficiency.
|
||||
*/
|
||||
const AUTO_GEN_MODEL = "haiku";
|
||||
const VOICE_AGENT_SYSTEM_INSTRUCTION = [
|
||||
"You are the Paseo voice assistant.",
|
||||
"The user cannot see your chat messages or tool calls.",
|
||||
"Always use the speak tool for all user-facing communication.",
|
||||
"Before calling any non-speak tool, first call speak with a short acknowledgement of what you heard and what you will do next.",
|
||||
"For long-running work, use speak to provide progress updates before and during execution.",
|
||||
"Treat the user input as transcribed speech and confirm intent in your first speak response to catch transcription errors early.",
|
||||
"If the transcription seems incomplete, cut off, or ambiguous, ask a clarifying question via speak before taking action.",
|
||||
"Use concise plain language suitable for speech output.",
|
||||
"Never use bash, file-edit, or web tools directly.",
|
||||
"Only use the paseo MCP tools.",
|
||||
].join(" ");
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
@@ -222,25 +215,6 @@ function escapeXmlText(value: string): string {
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export function buildVoiceAgentMcpServerConfig(params: {
|
||||
command: string;
|
||||
baseArgs: string[];
|
||||
socketPath: string;
|
||||
env?: Record<string, string>;
|
||||
}): {
|
||||
type: "stdio";
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
return {
|
||||
type: "stdio",
|
||||
command: params.command,
|
||||
args: [...params.baseArgs, "--socket", params.socketPath],
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type ProcessingPhase = "idle" | "transcribing" | "llm";
|
||||
|
||||
type NormalizedGitOptions = {
|
||||
@@ -1541,13 +1515,6 @@ export class Session {
|
||||
return JSON.parse(JSON.stringify(servers)) as Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
private buildVoiceModeSystemPrompt(existing?: string): string {
|
||||
const chunks = [existing?.trim(), VOICE_AGENT_SYSTEM_INSTRUCTION].filter(
|
||||
(value): value is string => Boolean(value && value.length > 0)
|
||||
);
|
||||
return chunks.join("\n\n");
|
||||
}
|
||||
|
||||
private buildVoiceModeMcpServers(
|
||||
existing: Record<string, McpServerConfig> | undefined,
|
||||
socketPath: string
|
||||
@@ -1579,12 +1546,12 @@ export class Session {
|
||||
this.registerVoiceBridgeForAgent(agentId);
|
||||
|
||||
const baseConfig: VoiceModeBaseConfig = {
|
||||
systemPrompt: existing.config.systemPrompt,
|
||||
systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt),
|
||||
mcpServers: this.cloneMcpServers(existing.config.mcpServers),
|
||||
};
|
||||
this.voiceModeBaseConfig = baseConfig;
|
||||
const refreshOverrides: Partial<AgentSessionConfig> = {
|
||||
systemPrompt: this.buildVoiceModeSystemPrompt(baseConfig.systemPrompt),
|
||||
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true),
|
||||
mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath),
|
||||
};
|
||||
|
||||
@@ -1625,7 +1592,7 @@ export class Session {
|
||||
const baseConfig = this.voiceModeBaseConfig;
|
||||
try {
|
||||
await this.agentManager.refreshAgentFromPersistence(agentId, {
|
||||
systemPrompt: baseConfig.systemPrompt,
|
||||
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false),
|
||||
mcpServers: this.cloneMcpServers(baseConfig.mcpServers),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildVoiceAgentMcpServerConfig } from "./session.js";
|
||||
import {
|
||||
buildVoiceAgentMcpServerConfig,
|
||||
buildVoiceModeSystemPrompt,
|
||||
stripVoiceModeSystemPrompt,
|
||||
} from "./voice-config.js";
|
||||
|
||||
describe("voice MCP stdio config", () => {
|
||||
test("builds stdio MCP config for voice agent", () => {
|
||||
@@ -25,3 +29,48 @@ describe("voice MCP stdio config", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice mode prompt instructions", () => {
|
||||
test("builds enabled voice instructions and preserves base prompt", () => {
|
||||
const prompt = buildVoiceModeSystemPrompt("Base system prompt", true);
|
||||
|
||||
expect(prompt).toContain("Base system prompt");
|
||||
expect(prompt).toContain("<paseo_voice_mode>");
|
||||
expect(prompt).toContain("Paseo voice mode is now on.");
|
||||
expect(prompt).toContain("Always use the speak tool for all user-facing communication.");
|
||||
expect(prompt).toContain("</paseo_voice_mode>");
|
||||
});
|
||||
|
||||
test("builds disabled voice instructions and supersedes previous voice block", () => {
|
||||
const existing = [
|
||||
"Base system prompt",
|
||||
"<paseo_voice_mode>",
|
||||
"legacy voice instruction",
|
||||
"</paseo_voice_mode>",
|
||||
].join("\n\n");
|
||||
|
||||
const prompt = buildVoiceModeSystemPrompt(existing, false);
|
||||
|
||||
expect(prompt).toContain("Base system prompt");
|
||||
expect(prompt).toContain("Paseo voice mode is now off.");
|
||||
expect(prompt).toContain("Ignore any earlier Paseo voice mode instructions in this thread.");
|
||||
expect(prompt.match(/<paseo_voice_mode>/g)?.length ?? 0).toBe(1);
|
||||
expect(prompt).not.toContain("legacy voice instruction");
|
||||
});
|
||||
|
||||
test("strips voice blocks from persisted prompt", () => {
|
||||
const existing = [
|
||||
"Base system prompt",
|
||||
"<paseo_voice_mode>",
|
||||
"legacy voice instruction",
|
||||
"</paseo_voice_mode>",
|
||||
].join("\n\n");
|
||||
|
||||
expect(stripVoiceModeSystemPrompt(existing)).toBe("Base system prompt");
|
||||
expect(
|
||||
stripVoiceModeSystemPrompt(
|
||||
["<paseo_voice_mode>", "legacy voice instruction", "</paseo_voice_mode>"].join("\n\n")
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
75
packages/server/src/server/voice-config.ts
Normal file
75
packages/server/src/server/voice-config.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
const VOICE_PROMPT_BLOCK_START = "<paseo_voice_mode>";
|
||||
const VOICE_PROMPT_BLOCK_END = "</paseo_voice_mode>";
|
||||
|
||||
const VOICE_AGENT_SYSTEM_INSTRUCTION = [
|
||||
"Paseo voice mode is now on.",
|
||||
"You are the Paseo voice assistant.",
|
||||
"The user cannot see your chat messages or tool calls.",
|
||||
"Always use the speak tool for all user-facing communication.",
|
||||
"Before calling any non-speak tool, first call speak with a short acknowledgement of what you heard and what you will do next.",
|
||||
"For long-running work, use speak to provide progress updates before and during execution.",
|
||||
"Treat the user input as transcribed speech.",
|
||||
"If the user intent is clear, proceed without extra confirmation.",
|
||||
"If the transcription seems incomplete, cut off, ambiguous, or may contain a non-obvious mistake or misspelling, ask a clarifying question via speak before taking action.",
|
||||
"Use concise plain language suitable for speech output.",
|
||||
].join(" ");
|
||||
|
||||
const VOICE_AGENT_DISABLED_INSTRUCTION = [
|
||||
"Paseo voice mode is now off.",
|
||||
"Ignore any earlier Paseo voice mode instructions in this thread.",
|
||||
].join(" ");
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function makeVoicePromptBlockRegex(): RegExp {
|
||||
return new RegExp(
|
||||
`${escapeRegExp(VOICE_PROMPT_BLOCK_START)}[\\s\\S]*?${escapeRegExp(
|
||||
VOICE_PROMPT_BLOCK_END
|
||||
)}`,
|
||||
"g"
|
||||
);
|
||||
}
|
||||
|
||||
export function stripVoiceModeSystemPrompt(existing?: string): string | undefined {
|
||||
const trimmed = existing?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const stripped = trimmed.replace(makeVoicePromptBlockRegex(), "").trim();
|
||||
return stripped.length > 0 ? stripped : undefined;
|
||||
}
|
||||
|
||||
export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled: boolean): string {
|
||||
const basePrompt = stripVoiceModeSystemPrompt(existing);
|
||||
const voiceInstruction = enabled
|
||||
? VOICE_AGENT_SYSTEM_INSTRUCTION
|
||||
: VOICE_AGENT_DISABLED_INSTRUCTION;
|
||||
const voiceBlock = [VOICE_PROMPT_BLOCK_START, voiceInstruction, VOICE_PROMPT_BLOCK_END].join(
|
||||
"\n"
|
||||
);
|
||||
|
||||
return [basePrompt, voiceBlock]
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildVoiceAgentMcpServerConfig(params: {
|
||||
command: string;
|
||||
baseArgs: string[];
|
||||
socketPath: string;
|
||||
env?: Record<string, string>;
|
||||
}): {
|
||||
type: "stdio";
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
return {
|
||||
type: "stdio",
|
||||
command: params.command,
|
||||
args: [...params.baseArgs, "--socket", params.socketPath],
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user