Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/timeflow/intelligence/realtime/instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
- 没提到地点,就当时间型日程处理,不要为了填 latitude/longitude 去调用 location_search
或编一个地点——地点型日程的地点仍然必须问清楚,这条只管时间型日程不要凭空加地点。
- 没说提醒方式,默认建一个 reminder_strength 为 medium 的提醒:非全天日程用
reminder_type=before_start、reminder_offset_minutes=200(开始前 200 分钟);全天日程用
reminder_type=before_start、reminder_offset_minutes=15(开始前 15 分钟);全天日程用
reminder_type=at_time、reminder_trigger_at 填当天上午 10:00(带时区偏移)。

地点怎么定
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat
case 'voice.dialogue.question':
// 缺字段/地点歧义之类的追问,对当前这轮来说就是系统的回复——记进历史,
// 不然标题过了这一阵子就变回通用文案,这句追问在记录里再也找不到。
this.updateLastTurnReply(message.payload.speech_text);
this.updateLastTurnReply(message.request_id, message.payload.speech_text);
this.setState({
conversationId: message.conversation_id,
phase: 'asking',
Expand All @@ -402,7 +402,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat
return;
case 'voice.dialogue.reply':
this.replyText = message.payload.speech_text;
this.updateLastTurnReply(message.payload.speech_text);
this.updateLastTurnReply(message.request_id, message.payload.speech_text);
this.notifyListeners();
return;
case 'voice.tts.start':
Expand Down Expand Up @@ -594,15 +594,21 @@ export class AssistantContinuousConversationService implements AssistantApplicat
return this.connection;
}

/** speech_text 是累计到目前为止的完整文字(不是增量),直接覆盖最后一轮即可。
* 没有轮次可更新时(理论上不会发生,reply 总跟在 asr.completed 后面)不做
* 任何事,不新建一条没有 transcript 的记录。 */
private updateLastTurnReply(replyText: string): void {
/** speech_text 是累计到目前为止的完整文字(不是增量),直接覆盖对应轮次即可。
* 必须按 request_id 找到它真正所属的那一轮,不能想当然地假设"最后一轮"——
* 麦克风连续开着,用户可能在上一轮的回复还没到达前就已经开口问了下一句,
* asr.completed 先把新一轮 push 进 turns,这条迟到的回复到达时"最后一轮"
* 已经变成了下一轮,会把上一轮的回复错记成下一轮的(下一轮回复还没来时
* 显示的会是上一轮内容)。找不到匹配 id(服务端没带 request_id 这种理论上
* 不该发生的情况)时退化成更新最后一轮,好歹不丢内容。 */
private updateLastTurnReply(requestId: string | undefined, replyText: string): void {
if (this.turns.length === 0) {
return;
}
const last = this.turns[this.turns.length - 1];
this.turns = [...this.turns.slice(0, -1), { ...last, replyText }];
const targetIndex =
requestId !== undefined ? this.turns.findIndex((turn) => turn.id === requestId) : -1;
const index = targetIndex === -1 ? this.turns.length - 1 : targetIndex;
this.turns = this.turns.map((turn, i) => (i === index ? { ...turn, replyText } : turn));
}

private armIdleTimer(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import {
FLOATING_VOICE_BAR_HEIGHT,
floatingVoiceBarBottomOffset,
} from '../../../shared/ui/floatingVoiceBarLayout';
import { colors, spacing } from '../../../shared/ui/theme';
import type { AssistantApplicationPort } from '../application/AssistantApplication';
import type { ConversationTurnState } from '../domain/ConversationTurn';
Expand Down Expand Up @@ -121,11 +125,19 @@ export function AssistantVoiceOverlay({
}

return (
<View
style={[styles.container, { paddingBottom: Math.max(spacing.md, insets.bottom) }]}
testID="assistant-voice-overlay"
>
<View pointerEvents="box-none" style={styles.overlay}>
<View pointerEvents="box-none" style={StyleSheet.absoluteFill} testID="assistant-voice-overlay">
{ptt.replyText ? (
<Pressable
accessibilityLabel="关闭回复"
onPress={ptt.dismissReply}
style={StyleSheet.absoluteFill}
/>
) : null}
<View
pointerEvents="box-none"
style={[styles.overlay, { bottom: floatingVoiceBarBottomOffset(insets.bottom) }]}
testID="assistant-voice-controls"
>
{ptt.replyText ? (
<Pressable onPress={ptt.dismissReply} style={styles.bubble}>
<Text style={styles.bubbleText}>{ptt.replyText}</Text>
Expand Down Expand Up @@ -170,14 +182,6 @@ const styles = StyleSheet.create({
gap: spacing.sm,
width: '100%',
},
container: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
width: '100%',
},
bubble: {
backgroundColor: colors.surface,
borderColor: colors.border,
Expand All @@ -195,7 +199,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
backgroundColor: colors.text,
borderRadius: 999,
height: 52,
height: FLOATING_VOICE_BAR_HEIGHT,
justifyContent: 'center',
width: 52,
},
Expand All @@ -207,6 +211,9 @@ const styles = StyleSheet.create({
},
overlay: {
alignItems: 'center',
width: '100%',
left: 0,
paddingHorizontal: spacing.lg,
position: 'absolute',
right: 0,
},
});
13 changes: 6 additions & 7 deletions frontend/src/features/assistant/presentation/PushToTalkBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Animated, Easing, Platform, Pressable, StyleSheet, Text, View } from 'react-native';

import { FLOATING_VOICE_BAR_HEIGHT } from '../../../shared/ui/floatingVoiceBarLayout';
import { colors, spacing } from '../../../shared/ui/theme';

const WAVE_BAR_HEIGHTS = [10, 16, 22, 16, 10] as const;
Expand Down Expand Up @@ -101,12 +102,10 @@ function normalizeLevel(dbfs: number | null): number {
const styles = StyleSheet.create({
bar: {
alignItems: 'center',
backgroundColor: colors.input,
borderColor: colors.border,
borderRadius: 14,
borderWidth: 1,
backgroundColor: colors.text,
borderRadius: 999,
flex: 1,
height: 52,
height: FLOATING_VOICE_BAR_HEIGHT,
justifyContent: 'center',
},
barActive: {
Expand All @@ -119,12 +118,12 @@ const styles = StyleSheet.create({
opacity: 0.86,
},
label: {
color: colors.text,
color: colors.onPrimary,
fontSize: 14,
fontWeight: '600',
},
labelDisabled: {
color: colors.mutedText,
color: colors.onPrimary,
},
wave: {
alignItems: 'center',
Expand Down
80 changes: 66 additions & 14 deletions frontend/src/features/assistant/presentation/VoiceCallScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import {
Animated,
Easing,
Expand All @@ -9,6 +10,7 @@ import {
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { colors, spacing } from '../../../shared/ui/theme';
import type { ConversationTurnRecord } from '../domain/ConversationTurn';
Expand All @@ -27,6 +29,7 @@ interface VoiceCallScreenProps {

const BREATH_SCALE = { duration: 1600, from: 1, to: 1.06 };
const TALK_SCALE = { duration: 650, from: 1, to: 1.14 };
const STICK_TO_BOTTOM_THRESHOLD = 48;

/**
* 免提通话的沉浸式全屏层:主体是一份可回看的完整问答记录(每轮一条用户话
Expand All @@ -44,8 +47,14 @@ export function VoiceCallScreen({
onEnd,
onTogglePause,
}: VoiceCallScreenProps) {
const insets = useSafeAreaInsets();
const [scale] = useState(() => new Animated.Value(1));
const [statusRowHeight, setStatusRowHeight] = useState(0);
const historyRef = useRef<ScrollView>(null);
// 回复是流式的(累计文字每收到一段就整段刷新一次),跟着一路自动滚会跟
// 用户手动上滑打架——用户一滑走就不再强制拉回底部,直到他自己滑回底部
// 附近才恢复跟随,不然会出现看似“滑不动”的情况。
const stickToBottomRef = useRef(true);

useEffect(() => {
scale.stopAnimation();
Expand Down Expand Up @@ -75,24 +84,58 @@ export function VoiceCallScreen({
return undefined;
}, [status, scale]);

// 只在用户真的拖动结束时才重新判断是否贴底——不能用 onScroll,流式内容
// 一直在长,我们自己触发的 scrollToEnd 也会产生 onScroll 事件,那时候
// contentSize 可能已经比滚动目标又长了一截,会被误判成“用户滑走了”,
// 之后就再也不会自动跟随,导致流式说完了还有一段没露出来。
// onScrollEndDrag/onMomentumScrollEnd 只在手指真正划过之后才触发,不受
// animated:false 的程序化跳转影响。
function handleScrollSettled(event: NativeSyntheticEvent<NativeScrollEvent>) {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
stickToBottomRef.current = distanceFromBottom <= STICK_TO_BOTTOM_THRESHOLD;
}

return (
<View style={styles.screen}>
<Pressable
accessibilityLabel="收起通话"
accessibilityRole="button"
onPress={onCollapse}
style={({ pressed }) => [styles.collapseButton, pressed && styles.buttonPressed]}
<View
style={[styles.navigation, { paddingTop: Math.max(spacing.md, insets.top) }]}
testID="voice-call-navigation"
>
<PhoneCallIcon color={colors.onPrimary} size={18} />
</Pressable>
<Pressable
accessibilityLabel="收起通话"
accessibilityRole="button"
onPress={onCollapse}
style={({ pressed }) => [styles.collapseButton, pressed && styles.buttonPressed]}
>
<PhoneCallIcon color={colors.onPrimary} size={18} />
</Pressable>
</View>

<ScrollView
ref={historyRef}
contentContainerStyle={[
styles.historyContent,
// 用实测的“聆听中”状态行高度兜底,不管这行到底多高、有没有跟
// ScrollView 自身的 flex 计算对不上,最后一条记录都保证能完整
// 露出来,不会被这一行挡住最后一点。
{ paddingBottom: spacing.xl + statusRowHeight },
turns.length === 0 && styles.historyContentEmpty,
]}
onContentSizeChange={() => historyRef.current?.scrollToEnd({ animated: true })}
onContentSizeChange={() => {
if (!stickToBottomRef.current) {
return;
}
// 延后两帧再滚:Android 上 onContentSizeChange 触发时原生 ScrollView
// 有时还没把新内容高度提交完,内容越高时越容易滚不到底。用
// animated:false 直接跳到底,流式刷新很密集,动画会跟下一次
// 内容变化互相打断、显得卡住不动。
requestAnimationFrame(() =>
requestAnimationFrame(() => historyRef.current?.scrollToEnd({ animated: false })),
);
}}
onMomentumScrollEnd={handleScrollSettled}
onScrollEndDrag={handleScrollSettled}
style={styles.history}
>
{turns.length > 0 ? (
Expand All @@ -109,7 +152,10 @@ export function VoiceCallScreen({
)}
</ScrollView>

<View style={styles.body}>
<View
onLayout={(event) => setStatusRowHeight(event.nativeEvent.layout.height)}
style={styles.body}
>
<Pressable
accessibilityLabel={status === 'paused' ? '继续' : '暂停'}
accessibilityRole="button"
Expand All @@ -130,7 +176,13 @@ export function VoiceCallScreen({
</Pressable>
</View>

<View style={styles.actions}>
<View
style={[
styles.actions,
{ paddingBottom: Math.max(spacing.xl, insets.bottom + spacing.md) },
]}
testID="voice-call-actions"
>
<Pressable
accessibilityLabel="结束对话"
accessibilityRole="button"
Expand Down Expand Up @@ -181,9 +233,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
height: 40,
justifyContent: 'center',
left: spacing.lg,
position: 'absolute',
top: spacing.xl,
width: 40,
},
endButton: {
Expand All @@ -192,13 +241,16 @@ const styles = StyleSheet.create({
endText: {
color: colors.onPrimary,
},
navigation: {
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
history: {
flex: 1,
paddingTop: spacing.xl * 2,
},
historyContent: {
gap: spacing.md,
paddingBottom: spacing.md,
paddingHorizontal: spacing.xl,
},
historyContentEmpty: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import {
ActivityIndicator,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';

import { floatingVoiceContentBottomInset } from '../../../shared/ui/floatingVoiceBarLayout';
import { colors, spacing } from '../../../shared/ui/theme';
import type { ScheduleCalendarReadService, ScheduleOccurrenceView } from '../application';
import { LocationScheduleDetailSheet } from './LocationScheduleDetailSheet';
Expand Down Expand Up @@ -41,6 +51,7 @@ export function ScheduleCalendarScreen({
refreshSignal,
focusTarget,
}: ScheduleCalendarScreenProps) {
const insets = useSafeAreaInsets();
const calendar = useScheduleCalendar(
service,
accountId,
Expand All @@ -56,6 +67,7 @@ export function ScheduleCalendarScreen({
null;
const selectedLocation =
calendar.locationSchedules.find((item) => item.scheduleId === selectedLocationId) ?? null;
const topSafeAreaPadding = Platform.OS === 'android' ? insets.top : 0;
const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate);
const agendaTitle = formatAgendaSectionTitle(calendar.selectedDate);
const emptyAgenda = emptyAgendaMessage(calendar.selectedDate);
Expand All @@ -65,9 +77,16 @@ export function ScheduleCalendarScreen({
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={styles.scrollContent}
contentContainerStyle={[
styles.scrollContent,
{
paddingBottom: floatingVoiceContentBottomInset(insets.bottom),
paddingTop: topSafeAreaPadding,
},
]}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
testID="schedule-calendar-scroll"
>
<View style={styles.content}>
<View style={styles.header}>
Expand Down
Loading
Loading