fix app-expo code file format

This commit is contained in:
Kevin
2026-05-11 10:25:06 +08:00
parent 219c833157
commit b9425e806b
10 changed files with 374 additions and 375 deletions

View File

@@ -241,7 +241,7 @@ jobs:
echo "$ALIYUN_CR_PASSWORD" | ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
"docker login $REGISTRY --username=$ALIYUN_CR_USERNAME --password-stdin"
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=6 -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "
ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "
set -euo pipefail
mkdir -p '$COMPOSE_DIR/api'
mkdir -p '$COMPOSE_DIR/api/backups'
@@ -275,7 +275,7 @@ jobs:
scp -P "$SSH_PORT" ./api/docker-compose.yml "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/docker-compose.candidate.yml"
scp -P "$SSH_PORT" "$ENV_SRC" "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/.env.candidate"
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=6 -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "
ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "
set -euo pipefail
cd '$COMPOSE_DIR/api'
echo '拉取候选镜像: $IMAGE_TAG'
@@ -291,7 +291,7 @@ jobs:
run: |
set -euo pipefail
echo "切换线上版本,容器启动时将自动执行 Alembic..."
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=6 -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "
ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "
set -euo pipefail
cd '$COMPOSE_DIR/api'
if [ -f '$COMPOSE_FILE' ]; then
@@ -316,7 +316,9 @@ jobs:
docker compose -f '$COMPOSE_FILE' logs --tail=80 celery-worker || true
exit 1
fi
echo '服务启动,输出当前状态...'
echo '等待服务启动...'
sleep 20
docker image prune -f || true
docker compose -f '$COMPOSE_FILE' ps
"

View File

@@ -122,7 +122,10 @@ function UserChatAvatar({
type InputMode = 'text' | 'voice';
/** 多段拆条后与后端 `ttsAudioUrls` 下标对齐 */
function assistantBubbleSegmentIndex(item: MessageItem, listKey: string): number {
function assistantBubbleSegmentIndex(
item: MessageItem,
listKey: string,
): number {
const part = /_part_(\d+)$/.exec(listKey);
if (part) return Number(part[1]);
const seg = /_seg_(\d+)$/.exec(item.id);
@@ -264,7 +267,10 @@ function MessageBubble({
const { t } = useTranslation('conversation');
const isUser = item.senderType === 'user';
const isVoice = isVoiceMessage(item);
const ttsUrlThisPart = segmentTtsUrlAt(item.ttsAudioUrls, assistantSegmentIndex);
const ttsUrlThisPart = segmentTtsUrlAt(
item.ttsAudioUrls,
assistantSegmentIndex,
);
const playbackKind = currentPlaybackItem?.kind;
const playbackRefListKey = currentPlaybackItem?.messageRef?.listKey;
@@ -276,11 +282,9 @@ function MessageBubble({
playbackMessageRefMatchesMessage(playbackRefListKey, item.id));
const playbackEngaged = playbackIsPlaying || playbackIsPaused;
const isThisBubbleActiveTts =
matchesThisMessageForTts && playbackEngaged;
const isThisBubbleActiveTts = matchesThisMessageForTts && playbackEngaged;
const isThisBubbleTtsPlaying =
isThisBubbleActiveTts && playbackIsPlaying;
const isThisBubbleTtsPlaying = isThisBubbleActiveTts && playbackIsPlaying;
const isThisBubbleTtsPaused = isThisBubbleActiveTts && playbackIsPaused;
const isAssistantTtsHighlight = isThisBubbleActiveTts;
@@ -334,10 +338,7 @@ function MessageBubble({
segmentText: item.content,
});
if (!ok) {
Alert.alert(
'',
t('readAloudRequestFailed'),
);
Alert.alert('', t('readAloudRequestFailed'));
}
} else {
Alert.alert('', t('readAloudNoMessageId'));
@@ -1088,8 +1089,7 @@ export default function ConversationScreen() {
const { data: profile } = useProfile();
const userAvatarUri = useMemo(
() =>
resolveApiMediaUrl(user?.avatar_url ?? profile?.avatar_url ?? null),
() => resolveApiMediaUrl(user?.avatar_url ?? profile?.avatar_url ?? null),
[user?.avatar_url, profile?.avatar_url],
);
const userAvatarLetter = useMemo(() => {
@@ -1287,8 +1287,7 @@ export default function ConversationScreen() {
next[idx] = {
...target,
id: nextId,
durableMessageId:
p.assistantMessageId ?? target.durableMessageId,
durableMessageId: p.assistantMessageId ?? target.durableMessageId,
ttsAudioUrls: nextUrls,
};
return next;
@@ -1639,7 +1638,9 @@ export default function ConversationScreen() {
<View style={styles.headerTitleBlock}>
<RNText
style={[styles.headerTitle, headerTitleFontStyle]}
{...(Platform.OS === 'android' ? { includeFontPadding: false } : {})}
{...(Platform.OS === 'android'
? { includeFontPadding: false }
: {})}
>
{tApp('name')}
</RNText>
@@ -1668,7 +1669,9 @@ export default function ConversationScreen() {
backAccessibilityLabel={t('chatTitle')}
right={
<View style={styles.headerTtsRow}>
<RNText style={headerTtsSwitchLabelStyle}>{t('ttsThisTurn')}</RNText>
<RNText style={headerTtsSwitchLabelStyle}>
{t('ttsThisTurn')}
</RNText>
<Switch
accessibilityLabel={t('ttsThisTurnAccessibility')}
value={ttsThisTurn}
@@ -1715,7 +1718,10 @@ export default function ConversationScreen() {
userAvatarUri={userAvatarUri}
userAvatarLetter={userAvatarLetter}
conversationId={id ?? ''}
assistantSegmentIndex={assistantBubbleSegmentIndex(item, item.listKey)}
assistantSegmentIndex={assistantBubbleSegmentIndex(
item,
item.listKey,
)}
durableAssistantId={durableAssistantIdForBubble(item, id ?? '')}
requestAssistantSegmentTts={requestAssistantSegmentTts}
/>
@@ -1740,8 +1746,7 @@ export default function ConversationScreen() {
agentName={t('agentName')}
streamingTtsActive={
!!streamingMessage &&
(playerStatus === 'playing' ||
playerStatus === 'paused') &&
(playerStatus === 'playing' || playerStatus === 'paused') &&
currentPlaybackItem?.kind === 'tts_auto'
}
onStreamingPress={handleInterruptAssistantTts}

View File

@@ -15,7 +15,10 @@ import {
ScrollView,
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import {
SafeAreaView,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -162,7 +165,10 @@ export default function PersonalInfoScreen() {
? err.message
: String(err);
if (nicknameCommitted) {
Alert.alert(t('personalInfo.savePartialTitle'), `${t('personalInfo.savePartialBody')}\n\n${msg}`);
Alert.alert(
t('personalInfo.savePartialTitle'),
`${t('personalInfo.savePartialBody')}\n\n${msg}`,
);
} else {
Alert.alert(t('personalInfo.saveFailed'), msg);
}
@@ -189,89 +195,91 @@ export default function PersonalInfoScreen() {
showsVerticalScrollIndicator={false}
>
<View className="gap-4 px-4 pt-4">
<View className="items-center gap-2">
<Pressable
accessibilityRole="button"
accessibilityLabel={t('personalInfo.changeAvatar')}
disabled={avatarBusy || profileLoading}
onPress={() => {
setAvatarStep('menu');
setAvatarModalOpen(true);
}}
>
<View className="relative h-28 w-28 overflow-hidden rounded-full bg-muted">
{avatarUri ? (
<Image
source={{ uri: avatarUri }}
style={{ width: '100%', height: '100%' }}
contentFit="cover"
accessibilityIgnoresInvertColors
<View className="items-center gap-2">
<Pressable
accessibilityRole="button"
accessibilityLabel={t('personalInfo.changeAvatar')}
disabled={avatarBusy || profileLoading}
onPress={() => {
setAvatarStep('menu');
setAvatarModalOpen(true);
}}
>
<View className="relative h-28 w-28 overflow-hidden rounded-full bg-muted">
{avatarUri ? (
<Image
source={{ uri: avatarUri }}
style={{ width: '100%', height: '100%' }}
contentFit="cover"
accessibilityIgnoresInvertColors
/>
) : (
<View className="flex-1 items-center justify-center">
<Text variant="large" className="text-muted-foreground">
{nickname.trim().slice(0, 1).toUpperCase() || '?'}
</Text>
</View>
)}
{avatarBusy ? (
<View className="absolute inset-0 items-center justify-center bg-background/60">
<ActivityIndicator />
</View>
) : null}
</View>
</Pressable>
<Text variant="bodySmall" className="text-muted-foreground">
{t('personalInfo.changeAvatar')}
</Text>
</View>
<View className="gap-3">
<Text variant="small" className="text-muted-foreground">
{t('personalInfo.nickname')}
</Text>
<Input
placeholder={t('personalInfo.nicknamePlaceholder')}
value={nickname}
onChangeText={setNickname}
autoCapitalize="none"
maxLength={50}
/>
) : (
<View className="flex-1 items-center justify-center">
<Text variant="large" className="text-muted-foreground">
{nickname.trim().slice(0, 1).toUpperCase() || '?'}
</Text>
</View>
)}
{avatarBusy ? (
<View className="absolute inset-0 items-center justify-center bg-background/60">
<ActivityIndicator />
</View>
<Input
placeholder={t('personalInfo.birthYearPlaceholder')}
value={birthYear}
onChangeText={setBirthYear}
keyboardType="number-pad"
/>
<Input
placeholder={t('personalInfo.birthPlacePlaceholder')}
value={birthPlace}
onChangeText={setBirthPlace}
/>
<Input
placeholder={t('personalInfo.grewUpPlaceholder')}
value={grewUpPlace}
onChangeText={setGrewUpPlace}
/>
<Input
placeholder={t('personalInfo.occupationPlaceholder')}
value={occupation}
onChangeText={setOccupation}
/>
</View>
{(update.error ?? updateNicknameMut.error) != null ? (
<Text className="text-sm text-destructive">
{(updateNicknameMut.error ?? update.error)?.message}
</Text>
) : null}
</View>
</Pressable>
<Text variant="bodySmall" className="text-muted-foreground">
{t('personalInfo.changeAvatar')}
</Text>
</View>
<View className="gap-3">
<Text variant="small" className="text-muted-foreground">
{t('personalInfo.nickname')}
</Text>
<Input
placeholder={t('personalInfo.nicknamePlaceholder')}
value={nickname}
onChangeText={setNickname}
autoCapitalize="none"
maxLength={50}
/>
<Input
placeholder={t('personalInfo.birthYearPlaceholder')}
value={birthYear}
onChangeText={setBirthYear}
keyboardType="number-pad"
/>
<Input
placeholder={t('personalInfo.birthPlacePlaceholder')}
value={birthPlace}
onChangeText={setBirthPlace}
/>
<Input
placeholder={t('personalInfo.grewUpPlaceholder')}
value={grewUpPlace}
onChangeText={setGrewUpPlace}
/>
<Input
placeholder={t('personalInfo.occupationPlaceholder')}
value={occupation}
onChangeText={setOccupation}
/>
</View>
{(update.error ?? updateNicknameMut.error) != null ? (
<Text className="text-sm text-destructive">
{(updateNicknameMut.error ?? update.error)?.message}
</Text>
) : null}
<Button
onPress={() => void handleSave()}
disabled={saving || profileLoading || !profile}
>
<Text>{saving ? t('personalInfo.saving') : t('personalInfo.save')}</Text>
</Button>
<Button
onPress={() => void handleSave()}
disabled={saving || profileLoading || !profile}
>
<Text>
{saving ? t('personalInfo.saving') : t('personalInfo.save')}
</Text>
</Button>
</View>
</ScrollView>
</SafeAreaView>

View File

@@ -511,9 +511,7 @@ export default function ConversationsScreen() {
<Pressable
className="items-center gap-6 rounded-2xl bg-muted/30 p-6 active:opacity-90"
onPress={handleResumeLatestConversation}
disabled={
isEnteringChat || createConversation.isPending
}
disabled={isEnteringChat || createConversation.isPending}
>
<Icon
as={MessageCirclePlus}

View File

@@ -172,10 +172,7 @@ export class WsClient {
return true;
}
sendText(
text: string,
opts?: { ttsThisTurn?: boolean },
): boolean {
sendText(text: string, opts?: { ttsThisTurn?: boolean }): boolean {
return this.send({
type: 'text',
data: {

View File

@@ -67,14 +67,11 @@ export async function buildAvatarUploadFormData(
return form;
}
form.append(
'file',
{
uri,
name: filename,
type: mime,
} as unknown as Blob,
);
form.append('file', {
uri,
name: filename,
type: mime,
} as unknown as Blob);
return form;
}

View File

@@ -188,8 +188,7 @@ export function useUpdateNickname() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: UpdateNicknameRequest) =>
authApi.updateNickname(body),
mutationFn: (body: UpdateNicknameRequest) => authApi.updateNickname(body),
onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user),
});
}

View File

@@ -143,10 +143,7 @@ export class RealtimeSession {
}
/** Returns true if the message was sent over the socket. */
sendText(
text: string,
options?: { ttsThisTurn?: boolean },
): boolean {
sendText(text: string, options?: { ttsThisTurn?: boolean }): boolean {
const tts = !!options?.ttsThisTurn;
this.assistantTurnTtsSync = tts;
return this.client.sendText(text, { ttsThisTurn: tts });
@@ -249,7 +246,10 @@ export class RealtimeSession {
};
if (this.assistantTurnTtsSync && !payload.manual) {
const idx = event.index ?? 0;
const key = RealtimeSession.bufferedTtsKey(event.assistantMessageId, idx);
const key = RealtimeSession.bufferedTtsKey(
event.assistantMessageId,
idx,
);
this.pendingTtsByKey.set(key, payload);
} else {
this.onTtsSegment?.(payload);

View File

@@ -1,247 +1,242 @@
// This file is automatically generated by i18next-cli. Do not edit manually.
interface Resources {
"app": {
"languages": {
"en": "English",
"system": "System",
"zh": "Chinese"
},
"name": "Life Echo",
"tabs": {
"conversations": "Chats",
"explore": "Explore",
"home": "Home",
"memoir": "Memoir",
"profile": "Profile"
},
"theme": {
"default": "Default"
}
},
"auth": {
"login": {
"codeLabel": "Verification Code",
"getCode": "Get Code",
"getCodeCountdown": "Retry in {{seconds}}s",
"networkError": "Network error. Please try again later.",
"phoneLabel": "Phone Number",
"phonePlaceholder": "Enter your phone number",
"privacyPolicy": "Privacy Policy",
"submit": "Login",
"termsAnd": "and",
"termsIntro": "I agree to the",
"termsRequired": "Please agree to the User Agreement and Privacy Policy first",
"termsRequiredConfirm": "OK",
"termsRequiredTitle": "Agreement Required",
"userAgreement": "User Agreement",
"welcomeSubtitle": "Some lives grow richer the more you savor them.",
"welcomeTitle": "Welcome back"
}
},
"common": {
"chapterLabel": "",
"chapterReading": {
"backgroundColor": "",
"bgPureWhite": "",
"bgSepia": "",
"close": "",
"fontSize": "",
"readingSettings": "",
"typography": ""
},
"continueWriting": "",
"docs": "Docs",
"emptySubtitle": "",
"emptyTitle": "",
"readMemory": "",
"startChapter": "",
"statusDrafting": "",
"statusLocked": "",
"statusPending": "",
"wordsCount": ""
},
"conversation": {
"addMore": "More",
"agentName": "Life Echo",
"assistantReplying": "Replying…",
"cancel": "Cancel",
"cancelRecording": "Cancel recording",
"cannotReadAloud": "Read unavailable",
"chatQueueSendTimeout": "Connection timed out. Check your network and try again.",
"chatTitle": "Conversation",
"chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.",
"chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.",
"chatUnavailableTitle": "Chat unavailable",
"confirm": "OK",
"confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.",
"connectionConnected": "Connected",
"connectionConnecting": "Connecting...",
"connectionDisconnected": "Disconnected",
"createError": "Unable to create conversation. Please check your network and try again.",
"delete": "Delete",
"deleteConversation": "Delete Conversation",
"emptyGreetingSubtitle": "Chat with your companion and record your stories.",
"greetingTitle": "Say Hello",
"inputPlaceholder": "Type a message...",
"inputPlaceholderVoice": "Type here or hold the mic to speak...",
"me": "Me",
"readAloudAgain": "Play again",
"readAloudPause": "Pause reading",
"readAloudResume": "Resume reading",
"readAloudRequest": "Read aloud",
"readAloudRequestFailed": "Could not start playback. Check your connection.",
"readAloudNoMessageId": "This message is not ready for on-demand reading yet. Pull to refresh or try again.",
"readingAloud": "Reading aloud…",
"recentChats": "Recent Chats",
"recordingPermissionDenied": "Microphone permission is required to record",
"recordingStartFailed": "Unable to start recording. Please try again.",
"resumeChatSubtitle": "Open your latest conversation to keep talking.",
"resumeChatTitle": "Continue chatting",
"send": "Send",
"startNewSubtitle": "Capture a new memory or share your thoughts with your companion.",
"stopReadingAloud": "Stop reading aloud",
"switchToText": "Switch to text input",
"switchToVoice": "Switch to voice input",
"tapToEndRecording": "Tap to end",
"tapToStartRecording": "Tap to start recording",
"ttsThisTurn": "Speak",
"ttsThisTurnAccessibility":
"When on, assistant replies synthesize speech before text appears.",
"timeDaysAgo_one": "{{count}} day ago",
"timeDaysAgo_other": "{{count}} days ago",
"timeHoursAgo_one": "{{count}} hour ago",
"timeHoursAgo_other": "{{count}} hours ago",
"timeJustNow": "Just now",
"timeMinutesAgo_one": "{{count}} minute ago",
"timeMinutesAgo_other": "{{count}} minutes ago",
"viewAll": "View All",
"voiceMessagePreview": "Voice message"
},
"explore": {
},
"home": {
},
"legal": {
"titlePrivacy": "Privacy Policy",
"titleTerms": "User Agreement"
},
"memoir": {
"chapterLabel": "Chapter {{index}}",
"chapterReading": {
"back": "Back",
"backgroundColor": "Background",
"bgPureWhite": "White",
"bgSepia": "Sepia",
"cancel": "Cancel",
"chapterNotFound": "Chapter not found",
"close": "Close",
"confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.",
"deleteChapter": "Delete Chapter",
"deleteChapterAction": "Delete",
"fontSans": "Sans",
"fontSerif": "Serif",
"fontSize": "Font Size",
"fontSizeDefault": "Medium",
"fontSizeLarge": "Large",
"fontSizeSmall": "Small",
"readingSettings": "Reading Settings",
"settings": "Settings",
"typography": "Typography"
},
"continueWriting": "Continue Writing",
"emptySubtitle": "Chat with your companion to record your stories",
"emptyTitle": "No memoir yet",
"frameworkChapters": {
"chapter1": "Childhood and upbringing",
"chapter2": "Education and young adulthood",
"chapter3": "Early career",
"chapter4": "Major achievements and peak moments",
"chapter5": "Setbacks, challenges, and turning points",
"chapter6": "Family and relationships",
"chapter7": "Beliefs and values",
"chapter8": "Life summary"
},
"loadErrorMessage": "Could not load chapters",
"loadErrorRetry": "Retry",
"pageTitle": "Memoir",
"readMemory": "Read Memory",
"startChapter": "Start Writing",
"statusDrafting": "Drafting",
"statusLocked": "Locked",
"statusPending": "Pending",
"wordsCount": "{{count}} words"
},
"profile": {
"about": {
"aboutUs": "About Us",
"title": "About"
},
"appExperience": {
"language": "Language",
"languageDesc": "Display language",
"largeText": "Large Text",
"largeTextDesc": "Make reading easier",
"nightMode": "Night Mode",
"nightModeDesc": "Use dark theme",
"theme": "Theme",
"themeDesc": "Color theme",
"title": "App Experience"
},
"dataPrivacy": {
"deleteAll": "Delete All Data",
"deleteUnderDevelopment": "Delete data feature is under development.",
"exportAll": "Export All Data",
"exportUnderDevelopment": "Export feature is under development.",
"purgeDialogCancel": "Cancel",
"purgeDialogConfirm": "Delete permanently",
"purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.",
"purgeDialogTitle": "Final confirmation",
"purgeInputLabel": "Confirmation phrase",
"purgeInputPlaceholder": "Type the phrase shown above",
"purgeOpenConfirm": "I understand, continue",
"purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:",
"purgeSubmitting": "Deleting…",
"purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.",
"purgeWarningTitle": "Before you continue",
"title": "Data & Privacy"
},
"editAvatar": "Edit Profile Picture",
"helpSupport": {
"faq": "FAQ",
"feedback": "Feedback & Support",
"feedbackPageTitle": "Share your thoughts",
"title": "Help & Support"
},
"personalInfo": {
"avatarPresetFailed": "Could not set preset avatar",
"avatarUploadFailed": "Could not upload avatar",
"birthPlacePlaceholder": "Birthplace",
"birthYearPlaceholder": "Birth year",
"cancel": "Cancel",
"changeAvatar": "Change photo",
"chooseFromLibrary": "Choose from library",
"choosePreset": "Preset avatars",
"grewUpPlaceholder": "Where you grew up",
"libraryPermissionDenied": "Photo library access is required to pick an image",
"nickname": "Nickname",
"nicknamePlaceholder": "Enter nickname",
"nicknameRequired": "Please enter a nickname",
"occupationPlaceholder": "Occupation",
"presetPickTitle": "Choose a preset",
"save": "Save",
"saveFailed": "Could not save",
"savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.",
"savePartialTitle": "Partially saved",
"saving": "Saving…",
"title": "Personal info"
},
"signOut": "Sign Out",
"signingOut": "Signing out...",
"userNamePlaceholder": "User",
"userTier": "{{tier}}"
}
app: {
languages: {
en: 'English';
system: 'System';
zh: 'Chinese';
};
name: 'Life Echo';
tabs: {
conversations: 'Chats';
explore: 'Explore';
home: 'Home';
memoir: 'Memoir';
profile: 'Profile';
};
theme: {
default: 'Default';
};
};
auth: {
login: {
codeLabel: 'Verification Code';
getCode: 'Get Code';
getCodeCountdown: 'Retry in {{seconds}}s';
networkError: 'Network error. Please try again later.';
phoneLabel: 'Phone Number';
phonePlaceholder: 'Enter your phone number';
privacyPolicy: 'Privacy Policy';
submit: 'Login';
termsAnd: 'and';
termsIntro: 'I agree to the';
termsRequired: 'Please agree to the User Agreement and Privacy Policy first';
termsRequiredConfirm: 'OK';
termsRequiredTitle: 'Agreement Required';
userAgreement: 'User Agreement';
welcomeSubtitle: 'Some lives grow richer the more you savor them.';
welcomeTitle: 'Welcome back';
};
};
common: {
chapterLabel: '';
chapterReading: {
backgroundColor: '';
bgPureWhite: '';
bgSepia: '';
close: '';
fontSize: '';
readingSettings: '';
typography: '';
};
continueWriting: '';
docs: 'Docs';
emptySubtitle: '';
emptyTitle: '';
readMemory: '';
startChapter: '';
statusDrafting: '';
statusLocked: '';
statusPending: '';
wordsCount: '';
};
conversation: {
addMore: 'More';
agentName: 'Life Echo';
assistantReplying: 'Replying…';
cancel: 'Cancel';
cancelRecording: 'Cancel recording';
cannotReadAloud: 'Read unavailable';
chatQueueSendTimeout: 'Connection timed out. Check your network and try again.';
chatTitle: 'Conversation';
chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.';
chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.';
chatUnavailableTitle: 'Chat unavailable';
confirm: 'OK';
confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.';
connectionConnected: 'Connected';
connectionConnecting: 'Connecting...';
connectionDisconnected: 'Disconnected';
createError: 'Unable to create conversation. Please check your network and try again.';
delete: 'Delete';
deleteConversation: 'Delete Conversation';
emptyGreetingSubtitle: 'Chat with your companion and record your stories.';
greetingTitle: 'Say Hello';
inputPlaceholder: 'Type a message...';
inputPlaceholderVoice: 'Type here or hold the mic to speak...';
me: 'Me';
readAloudAgain: 'Play again';
readAloudPause: 'Pause reading';
readAloudResume: 'Resume reading';
readAloudRequest: 'Read aloud';
readAloudRequestFailed: 'Could not start playback. Check your connection.';
readAloudNoMessageId: 'This message is not ready for on-demand reading yet. Pull to refresh or try again.';
readingAloud: 'Reading aloud…';
recentChats: 'Recent Chats';
recordingPermissionDenied: 'Microphone permission is required to record';
recordingStartFailed: 'Unable to start recording. Please try again.';
resumeChatSubtitle: 'Open your latest conversation to keep talking.';
resumeChatTitle: 'Continue chatting';
send: 'Send';
startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.';
stopReadingAloud: 'Stop reading aloud';
switchToText: 'Switch to text input';
switchToVoice: 'Switch to voice input';
tapToEndRecording: 'Tap to end';
tapToStartRecording: 'Tap to start recording';
ttsThisTurn: 'Speak';
ttsThisTurnAccessibility: 'When on, assistant replies synthesize speech before text appears.';
timeDaysAgo_one: '{{count}} day ago';
timeDaysAgo_other: '{{count}} days ago';
timeHoursAgo_one: '{{count}} hour ago';
timeHoursAgo_other: '{{count}} hours ago';
timeJustNow: 'Just now';
timeMinutesAgo_one: '{{count}} minute ago';
timeMinutesAgo_other: '{{count}} minutes ago';
viewAll: 'View All';
voiceMessagePreview: 'Voice message';
};
explore: {};
home: {};
legal: {
titlePrivacy: 'Privacy Policy';
titleTerms: 'User Agreement';
};
memoir: {
chapterLabel: 'Chapter {{index}}';
chapterReading: {
back: 'Back';
backgroundColor: 'Background';
bgPureWhite: 'White';
bgSepia: 'Sepia';
cancel: 'Cancel';
chapterNotFound: 'Chapter not found';
close: 'Close';
confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.';
deleteChapter: 'Delete Chapter';
deleteChapterAction: 'Delete';
fontSans: 'Sans';
fontSerif: 'Serif';
fontSize: 'Font Size';
fontSizeDefault: 'Medium';
fontSizeLarge: 'Large';
fontSizeSmall: 'Small';
readingSettings: 'Reading Settings';
settings: 'Settings';
typography: 'Typography';
};
continueWriting: 'Continue Writing';
emptySubtitle: 'Chat with your companion to record your stories';
emptyTitle: 'No memoir yet';
frameworkChapters: {
chapter1: 'Childhood and upbringing';
chapter2: 'Education and young adulthood';
chapter3: 'Early career';
chapter4: 'Major achievements and peak moments';
chapter5: 'Setbacks, challenges, and turning points';
chapter6: 'Family and relationships';
chapter7: 'Beliefs and values';
chapter8: 'Life summary';
};
loadErrorMessage: 'Could not load chapters';
loadErrorRetry: 'Retry';
pageTitle: 'Memoir';
readMemory: 'Read Memory';
startChapter: 'Start Writing';
statusDrafting: 'Drafting';
statusLocked: 'Locked';
statusPending: 'Pending';
wordsCount: '{{count}} words';
};
profile: {
about: {
aboutUs: 'About Us';
title: 'About';
};
appExperience: {
language: 'Language';
languageDesc: 'Display language';
largeText: 'Large Text';
largeTextDesc: 'Make reading easier';
nightMode: 'Night Mode';
nightModeDesc: 'Use dark theme';
theme: 'Theme';
themeDesc: 'Color theme';
title: 'App Experience';
};
dataPrivacy: {
deleteAll: 'Delete All Data';
deleteUnderDevelopment: 'Delete data feature is under development.';
exportAll: 'Export All Data';
exportUnderDevelopment: 'Export feature is under development.';
purgeDialogCancel: 'Cancel';
purgeDialogConfirm: 'Delete permanently';
purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.';
purgeDialogTitle: 'Final confirmation';
purgeInputLabel: 'Confirmation phrase';
purgeInputPlaceholder: 'Type the phrase shown above';
purgeOpenConfirm: 'I understand, continue';
purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:';
purgeSubmitting: 'Deleting…';
purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.';
purgeWarningTitle: 'Before you continue';
title: 'Data & Privacy';
};
editAvatar: 'Edit Profile Picture';
helpSupport: {
faq: 'FAQ';
feedback: 'Feedback & Support';
feedbackPageTitle: 'Share your thoughts';
title: 'Help & Support';
};
personalInfo: {
avatarPresetFailed: 'Could not set preset avatar';
avatarUploadFailed: 'Could not upload avatar';
birthPlacePlaceholder: 'Birthplace';
birthYearPlaceholder: 'Birth year';
cancel: 'Cancel';
changeAvatar: 'Change photo';
chooseFromLibrary: 'Choose from library';
choosePreset: 'Preset avatars';
grewUpPlaceholder: 'Where you grew up';
libraryPermissionDenied: 'Photo library access is required to pick an image';
nickname: 'Nickname';
nicknamePlaceholder: 'Enter nickname';
nicknameRequired: 'Please enter a nickname';
occupationPlaceholder: 'Occupation';
presetPickTitle: 'Choose a preset';
save: 'Save';
saveFailed: 'Could not save';
savePartialBody: 'Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.';
savePartialTitle: 'Partially saved';
saving: 'Saving…';
title: 'Personal info';
};
signOut: 'Sign Out';
signingOut: 'Signing out...';
userNamePlaceholder: 'User';
userTier: '{{tier}}';
};
}
export default Resources;

View File

@@ -98,16 +98,14 @@ describe('conversation entry warmup', () => {
expect(mockLoadMessages).toHaveBeenCalledWith('conv-1');
expect(mockSessions).toHaveLength(0);
expect(queryClient.getQueryData(conversationKeys.messages('conv-1'))).toEqual(
[existing],
);
expect(
queryClient.getQueryData(conversationKeys.messages('conv-1')),
).toEqual([existing]);
});
test('connects websocket and registers prepared session after opening arrives', async () => {
const opened = assistantMessage();
mockLoadMessages
.mockResolvedValueOnce([])
.mockResolvedValueOnce([opened]);
mockLoadMessages.mockResolvedValueOnce([]).mockResolvedValueOnce([opened]);
mockConnectImpl = ({ conversationId, queryClient }) => {
queryClient.setQueryData(conversationKeys.messages(conversationId), [
opened,