Merge remote-tracking branch 'origin/development'
33
.github/workflows/app-expo-deploy.yml
vendored
@@ -1,6 +1,12 @@
|
||||
# App Expo:CI 内生成 Android 签名 APK(expo prebuild + Gradle assembleRelease)
|
||||
# 使用 app-expo/plugins/withAndroidReleaseSigning:在 android/app 放置 keystore.properties + jks 后打 release 包。
|
||||
#
|
||||
# 产物说明:
|
||||
# - 以下为「真机向」release APK;Google Play 上架更推荐 AAB(按设备 ABI 下发)。
|
||||
# - 构建步骤会将 reactNativeArchitectures 设为仅 arm64-v8a(更小;不含 x86/x86_64,多数模拟器无法安装)。
|
||||
# - 若需兼容旧 32 位 ARM 设备,可将该属性改为 armeabi-v7a,arm64-v8a(体积会增大)。
|
||||
# - 若需用 x86 模拟器验证此 APK,需改回含 x86 的 ABI 或另建 job。
|
||||
#
|
||||
# 环境映射(按触发源自动推断):
|
||||
# main → dev (开发 + 内部测试)
|
||||
# v*.*.* → prod (正式发布 + GitHub Release 附带 APK)
|
||||
@@ -41,6 +47,8 @@ concurrency:
|
||||
|
||||
env:
|
||||
APP_NAME: app-expo
|
||||
EXPO_NO_TELEMETRY: 1
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -51,7 +59,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: ${{ startsWith(github.ref, 'refs/tags/') && '0' || '1' }}
|
||||
|
||||
@@ -67,9 +75,9 @@ jobs:
|
||||
fi >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.22.1
|
||||
cache: npm
|
||||
cache-dependency-path: app-expo/package-lock.json
|
||||
|
||||
@@ -116,18 +124,29 @@ jobs:
|
||||
echo "版本名: ${VERSION}, versionCode: ${VERSION_CODE}, tag: ${TAG_NAME}"
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Expo prebuild (Android)
|
||||
working-directory: app-expo
|
||||
run: npx expo prebuild --platform android --clean
|
||||
|
||||
- name: Release APK — arm64 only (real devices; smaller)
|
||||
working-directory: app-expo/android
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if grep -q '^reactNativeArchitectures=' gradle.properties; then
|
||||
sed -i 's/^reactNativeArchitectures=.*/reactNativeArchitectures=arm64-v8a/' gradle.properties
|
||||
else
|
||||
echo 'reactNativeArchitectures=arm64-v8a' >> gradle.properties
|
||||
fi
|
||||
grep -E '^reactNativeArchitectures=' gradle.properties
|
||||
|
||||
- name: Decode keystore
|
||||
working-directory: app-expo/android/app
|
||||
run: |
|
||||
@@ -166,7 +185,7 @@ jobs:
|
||||
du -h "$FINAL_PATH"
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.version.outputs.apk_name }}
|
||||
path: app-expo/android/${{ steps.apk.outputs.apk_path }}
|
||||
@@ -198,7 +217,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release (prod)
|
||||
if: steps.env.outputs.env == 'prod'
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag_name }}
|
||||
name: "${{ env.APP_NAME }} ${{ steps.version.outputs.tag_name }}"
|
||||
|
||||
29
.github/workflows/docker-build-deploy.yml
vendored
@@ -41,6 +41,7 @@ env:
|
||||
IMAGE_NAME: lifecho-api
|
||||
REGISTRY: crpi-u2903xccyzd6nqnc.cn-shanghai.personal.cr.aliyuncs.com
|
||||
REGISTRY_NAMESPACE: huaga
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -51,7 +52,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
|
||||
@@ -88,7 +89,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./api
|
||||
file: ./api/Dockerfile
|
||||
@@ -106,7 +107,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
|
||||
@@ -147,13 +148,13 @@ jobs:
|
||||
# 勿用 `prod && PROD_KEY || DEV_KEY`:PROD 为空时会错误回退到 DEV 密钥,导致连生产机报 Permission denied。
|
||||
- name: Set up SSH (production)
|
||||
if: steps.deploy_target.outputs.target == 'prod'
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up SSH (development)
|
||||
if: steps.deploy_target.outputs.target != 'prod'
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
||||
|
||||
@@ -221,8 +222,9 @@ jobs:
|
||||
docker network inspect api_life-echo-network >/dev/null 2>&1 || docker network create api_life-echo-network
|
||||
"
|
||||
|
||||
echo "上传候选 compose 与环境变量..."
|
||||
echo "上传候选 compose、Caddyfile 与环境变量..."
|
||||
scp -P "$SSH_PORT" ./api/docker-compose.yml "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/docker-compose.candidate.yml"
|
||||
scp -P "$SSH_PORT" ./api/Caddyfile "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/Caddyfile.candidate"
|
||||
scp -P "$SSH_PORT" ./api/.env.production "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/.env.production.candidate"
|
||||
|
||||
ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "
|
||||
@@ -247,10 +249,14 @@ jobs:
|
||||
if [ -f '$COMPOSE_FILE' ]; then
|
||||
cp '$COMPOSE_FILE' '${COMPOSE_FILE}.predeploy'
|
||||
fi
|
||||
if [ -f 'Caddyfile' ]; then
|
||||
cp 'Caddyfile' 'Caddyfile.predeploy'
|
||||
fi
|
||||
if [ -f '.env.production' ]; then
|
||||
cp '.env.production' '.env.production.predeploy'
|
||||
fi
|
||||
mv 'docker-compose.candidate.yml' '$COMPOSE_FILE'
|
||||
mv 'Caddyfile.candidate' 'Caddyfile'
|
||||
mv '.env.production.candidate' '.env.production'
|
||||
docker-compose -f '$COMPOSE_FILE' up -d --remove-orphans
|
||||
echo '等待服务启动...'
|
||||
@@ -290,5 +296,16 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CADDY_CID=\$(docker-compose ps -q caddy || true)
|
||||
if [ -n \"\$CADDY_CID\" ]; then
|
||||
CADDY_STATE=\$(docker inspect -f '{{.State.Status}}' \"\$CADDY_CID\")
|
||||
echo \"caddy state: \$CADDY_STATE\"
|
||||
if [ \"\$CADDY_STATE\" != 'running' ]; then
|
||||
echo 'caddy 容器未处于 running 状态'
|
||||
docker-compose logs --tail=80 caddy || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
docker-compose logs --tail=50 api
|
||||
"
|
||||
|
||||
4
api/Caddyfile
Normal file
@@ -0,0 +1,4 @@
|
||||
{$CADDY_PRIMARY_DOMAIN:lifecho.worldsplats.com} {
|
||||
encode zstd gzip
|
||||
reverse_proxy api:8000
|
||||
}
|
||||
@@ -57,8 +57,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: life-echo-api:latest
|
||||
container_name: life-echo-api-prod
|
||||
ports:
|
||||
- "8000:8000"
|
||||
expose:
|
||||
- "8000"
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "celery -A app.tasks.celery_app inspect ping --timeout 10 2>/dev/null | grep -q pong || exit 1"]
|
||||
test: ["CMD-SHELL", "uv run celery -A app.tasks.celery_app inspect ping --timeout 10 2>/dev/null | grep -q pong || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
@@ -122,6 +122,28 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: life-echo-caddy
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
restart: always
|
||||
networks:
|
||||
- life-echo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Celery Beat(定时任务调度,可选)
|
||||
# celery-beat:
|
||||
# build:
|
||||
@@ -175,3 +197,7 @@ volumes:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
caddy_data:
|
||||
driver: local
|
||||
caddy_config:
|
||||
driver: local
|
||||
|
||||
@@ -118,7 +118,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
userInterfaceStyle: 'automatic',
|
||||
ios: {
|
||||
...config?.ios,
|
||||
icon: './assets/expo.icon',
|
||||
icon: './assets/images/icon.png',
|
||||
bundleIdentifier: 'com.anonymous.app-expo',
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
@@ -142,7 +142,6 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
adaptiveIcon: {
|
||||
backgroundColor: '#E6F4FE',
|
||||
foregroundImage: './assets/images/android-icon-foreground.png',
|
||||
backgroundImage: './assets/images/android-icon-background.png',
|
||||
monochromeImage: './assets/images/android-icon-monochrome.png',
|
||||
},
|
||||
predictiveBackGestureEnabled: false,
|
||||
@@ -168,11 +167,11 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
[
|
||||
'expo-splash-screen',
|
||||
{
|
||||
backgroundColor: '#208AEF',
|
||||
android: {
|
||||
image: './assets/images/splash-icon.png',
|
||||
imageWidth: 76,
|
||||
},
|
||||
// 与 android.adaptiveIcon.backgroundColor、品牌浅紫一致(见 scripts/generate-app-icon.sh)
|
||||
backgroundColor: '#E6F4FE',
|
||||
image: './assets/images/splash-icon.png',
|
||||
resizeMode: 'contain',
|
||||
imageWidth: 200,
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 780 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 43 KiB |
BIN
app-expo/assets/life-echo-logo.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
48
app-expo/scripts/generate-app-icon.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一次性:用 ImageMagick 7+(magick)从 assets/life-echo-logo.jpg 生成各平台 PNG。
|
||||
# 依赖:brew install imagemagick
|
||||
# 用法:在 app-expo 目录执行 ./scripts/generate-app-icon.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SRC="$ROOT/assets/life-echo-logo.jpg"
|
||||
OUT="$ROOT/assets/images"
|
||||
# 与 Android adaptiveIcon.backgroundColor、开屏底色一致(新 logo 浅紫系)
|
||||
BRAND_BG="#E6F4FE"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
if [[ ! -f "$SRC" ]]; then
|
||||
echo "missing: $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v magick >/dev/null; then
|
||||
echo "need: magick (ImageMagick 7)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# 近白背景 → 透明(JPG 白底)
|
||||
magick "$SRC" -fuzz 12% -transparent white PNG32:"$TMP/fg.png"
|
||||
|
||||
# Android 自适应前景(透明底,1024,内容约 78% 安全区)
|
||||
magick "$TMP/fg.png" -resize '800x800>' -background none -gravity center -extent 1024x1024 \
|
||||
PNG32:"$OUT/android-icon-foreground.png"
|
||||
|
||||
# 通用 / iOS 主图标(实色底)
|
||||
magick -size 1024x1024 "xc:${BRAND_BG}" \( "$TMP/fg.png" -resize '800x800>' \) -gravity center -compose over -composite \
|
||||
PNG32:"$OUT/icon.png"
|
||||
|
||||
# Android 13+ 单色层(白/透明)
|
||||
magick "$OUT/android-icon-foreground.png" -alpha extract -fill white -colorize 100 -background none -alpha shape \
|
||||
PNG32:"$OUT/android-icon-monochrome.png"
|
||||
|
||||
# 开屏图:仅透明 logo(底色由 expo-splash-screen backgroundColor 填充)
|
||||
magick "$SRC" -fuzz 12% -transparent white -resize '400x400>' PNG32:"$OUT/splash-icon.png"
|
||||
|
||||
magick "$OUT/icon.png" -resize 48x48 PNG32:"$OUT/favicon.png"
|
||||
|
||||
echo "OK → $OUT/icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png, favicon.png"
|
||||
@@ -501,7 +501,6 @@ function ChatInputBar({
|
||||
styles.iconButton,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
disabled={disabled && !isRecording}
|
||||
accessibilityLabel={
|
||||
inputMode === 'text' ? switchToVoiceLabel : switchToTextLabel
|
||||
}
|
||||
@@ -510,11 +509,7 @@ function ChatInputBar({
|
||||
<Icon
|
||||
as={inputMode === 'text' ? Mic : Type}
|
||||
size={22}
|
||||
color={
|
||||
disabled && !isRecording
|
||||
? 'rgba(141, 140, 144, 0.56)'
|
||||
: CHAT_COLORS.onSurfaceVariant
|
||||
}
|
||||
color={CHAT_COLORS.onSurfaceVariant}
|
||||
/>
|
||||
</Pressable>
|
||||
{/* 中间:文字输入 或 语音录制按钮 */}
|
||||
@@ -531,7 +526,7 @@ function ChatInputBar({
|
||||
scrollEnabled
|
||||
textAlignVertical="top"
|
||||
maxLength={2000}
|
||||
editable={!disabled}
|
||||
editable
|
||||
onContentSizeChange={onInputContentSizeChange}
|
||||
onSubmitEditing={onSend}
|
||||
returnKeyType="send"
|
||||
@@ -715,6 +710,15 @@ export default function ConversationScreen() {
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (connectionState !== 'connected') {
|
||||
Alert.alert(
|
||||
t('chatUnavailableTitle'),
|
||||
connectionState === 'connecting'
|
||||
? t('chatUnavailableConnecting')
|
||||
: t('chatUnavailableDisconnected'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sendText(text);
|
||||
setInput('');
|
||||
setInputResetKey((k) => k + 1);
|
||||
@@ -726,6 +730,12 @@ export default function ConversationScreen() {
|
||||
: connectionState === 'connecting'
|
||||
? t('connectionConnecting')
|
||||
: t('connectionDisconnected');
|
||||
const showConnectionBadge = __DEV__;
|
||||
const showConnectionNotice = connectionState !== 'connected';
|
||||
const connectionNoticeText =
|
||||
connectionState === 'connecting'
|
||||
? t('chatUnavailableConnecting')
|
||||
: t('chatUnavailableDisconnected');
|
||||
|
||||
/** iOS:用键盘高度直接顶起根布局,替代 KAV(避免与 safe area 叠出缝,见 RN #52626) */
|
||||
const keyboardLift =
|
||||
@@ -744,22 +754,25 @@ export default function ConversationScreen() {
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={styles.headerTitle}>{tApp('name')}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
connectionState === 'connected' && styles.statusBadgeConnected,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
{showConnectionBadge ? (
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
styles.statusBadge,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeTextConnected,
|
||||
styles.statusBadgeConnected,
|
||||
]}
|
||||
>
|
||||
{connectionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeTextConnected,
|
||||
]}
|
||||
>
|
||||
{connectionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
}
|
||||
backAccessibilityLabel={t('chatTitle')}
|
||||
@@ -810,6 +823,16 @@ export default function ConversationScreen() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
{showConnectionNotice ? (
|
||||
<View style={styles.connectionNotice}>
|
||||
<Text style={styles.connectionNoticeTitle}>
|
||||
{t('chatUnavailableTitle')}
|
||||
</Text>
|
||||
<Text style={styles.connectionNoticeText}>
|
||||
{connectionNoticeText}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
@@ -1028,6 +1051,28 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
connectionNotice: {
|
||||
marginHorizontal: 14,
|
||||
marginTop: 14,
|
||||
marginBottom: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(186, 26, 26, 0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(186, 26, 26, 0.14)',
|
||||
},
|
||||
connectionNoticeTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: CHAT_COLORS.errorRed,
|
||||
marginBottom: 2,
|
||||
},
|
||||
connectionNoticeText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: CHAT_COLORS.onSurface,
|
||||
},
|
||||
inputBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -66,6 +66,9 @@ interface Resources {
|
||||
cancel: 'Cancel';
|
||||
cancelRecording: 'Cancel recording';
|
||||
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';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.",
|
||||
"createError": "Unable to create conversation. Please check your network and try again.",
|
||||
"chatUnavailableTitle": "Chat unavailable",
|
||||
"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.",
|
||||
"confirm": "OK",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"confirmDeleteConversation": "确定要删除此对话吗?删除后无法恢复。",
|
||||
"createError": "无法创建对话,请检查网络连接或稍后重试",
|
||||
"chatUnavailableTitle": "聊天暂不可用",
|
||||
"chatUnavailableConnecting": "正在重新连接。你仍可继续输入,恢复后再发送。",
|
||||
"chatUnavailableDisconnected": "当前连接已断开。你仍可先输入,连接恢复后再发送。",
|
||||
"confirm": "知道了",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
|
||||