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
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.git
.github
.next
**/.next
node_modules
**/node_modules
.env
.env.*
!.env.example
!**/.env.example
*.log
coverage
playwright-report
test-results
apps/mobile/ios
apps/mobile/android
apps/mobile/.expo
docs
129 changes: 88 additions & 41 deletions .github/workflows/deploy-tencent.yml
Original file line number Diff line number Diff line change
@@ -1,72 +1,119 @@
name: Deploy Tencent
name: Deploy Tencent Docker

permissions:
contents: read

on:
pull_request:
paths:
- .dockerignore
- Dockerfile
- deploy/**
- apps/web/next.config.ts
- apps/web/app/api/health/**
- package.json
- package-lock.json
- apps/web/package.json
- packages/**
- .github/workflows/deploy-tencent.yml
push:
branches:
- main
- release
workflow_dispatch:

concurrency:
group: deploy-tencent-${{ github.ref }}
group: deploy-tencent-docker-${{ github.ref }}
cancel-in-progress: false

env:
IMAGE_NAME: meteorvoice-web

jobs:
build:
name: Validate deployment image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/amd64
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Upload validated source artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: meteorvoice-source-${{ github.sha }}
path: |
.
!.git
!node_modules
!apps/**/node_modules
!packages/**/node_modules
!apps/**/.next
retention-days: 7

deploy:
name: Deploy Web/API
name: Deploy Web/API container
if: github.event_name != 'pull_request'
needs: build
runs-on: [self-hosted, linux, x64, tencent, meteorvoice]
environment: tencent
steps:
- name: Download validated source artifact
uses: actions/download-artifact@v4
with:
name: meteorvoice-source-${{ github.sha }}
path: source

- name: Resolve target
id: target
shell: bash
run: |
if [ "${GITHUB_REF_NAME}" = "release" ]; then
echo "app_dir=/srv/meteorvoice-release" >> "$GITHUB_OUTPUT"
echo "pm2_name=meteorvoice-release" >> "$GITHUB_OUTPUT"
echo "environment=production" >> "$GITHUB_OUTPUT"
echo "port=3100" >> "$GITHUB_OUTPUT"
echo "shadow_port=3110" >> "$GITHUB_OUTPUT"
echo "pm2_name=meteorvoice-release" >> "$GITHUB_OUTPUT"
echo "public_url=https://meteorvoice.jcmeteor.com/" >> "$GITHUB_OUTPUT"
else
echo "app_dir=/srv/meteorvoice" >> "$GITHUB_OUTPUT"
echo "pm2_name=meteorvoice" >> "$GITHUB_OUTPUT"
echo "environment=preview" >> "$GITHUB_OUTPUT"
echo "port=3101" >> "$GITHUB_OUTPUT"
echo "shadow_port=3111" >> "$GITHUB_OUTPUT"
echo "pm2_name=meteorvoice" >> "$GITHUB_OUTPUT"
echo "public_url=https://mv-pre.jcmeteor.com/" >> "$GITHUB_OUTPUT"
fi

- name: Build image from artifact
run: >-
docker build
--build-arg NPM_REGISTRY=https://registry.npmmirror.com
--tag "meteorvoice-web:${GITHUB_SHA}"
--file source/Dockerfile
source

- name: Deploy
shell: bash
env:
APP_NAME: meteorvoice
ENVIRONMENT: ${{ steps.target.outputs.environment }}
HOST_PORT: ${{ steps.target.outputs.port }}
IMAGE_URI: meteorvoice-web:${{ github.sha }}
PM2_NAME: ${{ steps.target.outputs.pm2_name }}
PUBLIC_URL: ${{ steps.target.outputs.public_url }}
RUNTIME_ENV_FILE: /etc/meteorvoice/meteorvoice.env
SHADOW_PORT: ${{ steps.target.outputs.shadow_port }}
run: |
retry() {
local attempts="$1"
local delay="$2"
shift 2

local attempt=1
until "$@"; do
if [ "$attempt" -ge "$attempts" ]; then
return 1
fi

echo "Command failed. Retrying in ${delay}s (${attempt}/${attempts})..."
sleep "$delay"
attempt=$((attempt + 1))
done
}

cd '${{ steps.target.outputs.app_dir }}'
retry 5 15 timeout 180s git fetch origin "$GITHUB_REF_NAME"
git checkout "$GITHUB_REF_NAME"
git reset --hard "origin/$GITHUB_REF_NAME"
retry 3 15 npm ci
set -a
. /etc/meteorvoice/meteorvoice.env
set +a
retry 2 15 npm run build
unset RUNNER_TRACKING_ID
PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \
pm2 restart '${{ steps.target.outputs.pm2_name }}' --update-env || \
PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \
pm2 start npm --name '${{ steps.target.outputs.pm2_name }}' -- run start --workspace @meteorvoice/web -- --hostname 127.0.0.1 --port '${{ steps.target.outputs.port }}'
pm2 save
retry 12 5 curl -fsS 'http://127.0.0.1:${{ steps.target.outputs.port }}/' >/dev/null
chmod +x source/deploy/deploy-container.sh
source/deploy/deploy-container.sh
41 changes: 41 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
FROM node:24-bookworm-slim AS dependencies

WORKDIR /app

ARG NPM_REGISTRY=https://registry.npmjs.org

COPY package.json package-lock.json ./
COPY apps/web/package.json apps/web/package.json
COPY apps/mobile/package.json apps/mobile/package.json
COPY packages/api-client/package.json packages/api-client/package.json
COPY packages/session-core/package.json packages/session-core/package.json
COPY packages/shared/package.json packages/shared/package.json

RUN npm ci --registry="${NPM_REGISTRY}"

FROM dependencies AS builder

COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:24-bookworm-slim AS runtime

ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
HOSTNAME=0.0.0.0 \
PORT=3000

WORKDIR /app

RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs

COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public

USER nextjs
EXPOSE 3000

CMD ["node", "apps/web/server.js"]
6 changes: 3 additions & 3 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
"name": "MeteorVoice",
"slug": "meteorvoice-mobile",
"owner": "junchenmeteors-team",
"version": "1.4.1",
"version": "1.4.2",
"orientation": "portrait",
"scheme": "meteorvoice",
"userInterfaceStyle": "automatic",
"icon": "./assets/icon.png",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.jcmeteor.meteorvoice",
"buildNumber": "2026071102",
"buildNumber": "2026071103",
"deploymentTarget": "18.0",
"infoPlist": {
"NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.",
Expand All @@ -26,7 +26,7 @@
},
"android": {
"package": "com.jcmeteor.meteorvoice",
"versionCode": 2026071102,
"versionCode": 2026071103,
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
Expand Down
4 changes: 0 additions & 4 deletions apps/mobile/app/(tabs)/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

import { useRouter } from 'expo-router'

import { scenarios } from '@meteorvoice/shared'

import { HomeScreen } from '../../src/screens/HomeScreen'
import { useSession } from '../../src/SessionContext'

Expand All @@ -18,8 +16,6 @@ export default function HomeTab() {
<HomeScreen
tr={tr}
locale={locale}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
scenarios={scenarios as any}
onGoToSession={() => router.replace('/(tabs)/session')}
/>
)
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/app/(tabs)/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ import { SessionScreen } from '../../src/screens/SessionScreen'
import { useSession } from '../../src/SessionContext'

export default function SessionTab() {
const { tr, locale, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion } = useSession()
const { tr, locale, availableScenarios, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion } = useSession()

const { scenario, accent } = useMemo(() => {
const s = scenarios.find(x => x.key === selectedScenarioKey) ?? scenarios[0]
const s = availableScenarios.find(x => x.key === selectedScenarioKey) ?? availableScenarios[0] ?? scenarios[0]
const a = accentProfiles.find(x => x.key === selectedAccentKey) ?? accentProfiles[0]
return { scenario: s, accent: a }
}, [selectedScenarioKey, selectedAccentKey])
}, [availableScenarios, selectedScenarioKey, selectedAccentKey])

return (
<SessionScreen
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@
CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2026071102;
CURRENT_PROJECT_VERSION = 2026071103;
DEVELOPMENT_TEAM = VY69DPYH3T;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
Expand All @@ -351,7 +351,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = 1.4.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand All @@ -376,15 +376,15 @@
CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2026071102;
CURRENT_PROJECT_VERSION = 2026071103;
DEVELOPMENT_TEAM = VY69DPYH3T;
INFOPLIST_FILE = MeteorVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = 1.4.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@meteorvoice/mobile",
"version": "1.4.1",
"version": "1.4.2",
"private": true,
"main": "index.ts",
"scripts": {
Expand Down
29 changes: 27 additions & 2 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
ConversationMessage,
ConversationResponse,
Locale,
Scenario,
TranslateFn,
} from '@meteorvoice/shared'
import type { SyncSessionRequest } from '@meteorvoice/api-client'
Expand Down Expand Up @@ -114,6 +115,7 @@ import {
STT_MAX_CONSECUTIVE_RESTARTS,
withTimeout,
} from './sessionRuntime'
import { resolveRuntimeScenarios } from './runtimeScenarios'

const defaultApiBaseUrl = getDefaultApiBaseUrl()
const appVersion = getDisplayAppVersion()
Expand Down Expand Up @@ -172,6 +174,7 @@ function AppInner({ children }: { children?: React.ReactNode }) {

// ─── Scenario & Accent / 场景与口音 ───
const [selectedScenarioKey, setSelectedScenarioKey] = useState('small-talk')
const [availableScenarios, setAvailableScenarios] = useState<Scenario[]>(scenarios)
const [selectedAccentKey, setSelectedAccentKey] = useState('american')
const [scenarioSwitching, setScenarioSwitching] = useState(false)
const [apiSessionId] = useState<string | null>(null)
Expand Down Expand Up @@ -232,8 +235,29 @@ function AppInner({ children }: { children?: React.ReactNode }) {
return () => { cancelled = true }
}, [api, applyTtsPreferences, auth.state, logMetric])

useEffect(() => {
let cancelled = false
void api.listScenarios(locale)
.then(result => {
if (!cancelled) setAvailableScenarios(resolveRuntimeScenarios(result.scenarios))
})
.catch(error => {
if (!cancelled) setAvailableScenarios(scenarios)
logMetric('mobile_scenarios_load_error', {
message: error instanceof Error ? error.message : 'unknown',
})
})
return () => { cancelled = true }
}, [api, auth.state, locale, logMetric])

// ─── Derived Values / 派生值 ───
const scenario = useMemo(() => scenarios.find(s => s.key === selectedScenarioKey) ?? scenarios[0], [selectedScenarioKey])
const scenario = useMemo(
() => availableScenarios.find(s => s.key === selectedScenarioKey)
?? scenarios.find(s => s.key === selectedScenarioKey)
?? availableScenarios[0]
?? scenarios[0],
[availableScenarios, selectedScenarioKey],
)
const accent = useMemo(() => accentProfiles.find(a => a.key === selectedAccentKey) ?? accentProfiles[0], [selectedAccentKey])

// Voice profile accent override (for SettingsScreen voice selection)
Expand Down Expand Up @@ -868,6 +892,7 @@ function AppInner({ children }: { children?: React.ReactNode }) {
// ─── SessionContext Value / 会话上下文值 ───
const sessionContext = useMemo<SessionContextValue>(() => ({
appVersion, applyTtsPreferences, auth, defaultApiBaseUrl, getAuthHeaders, handleUnauthorized, signOut,
availableScenarios,
snapshot, messages, corrections: correctionHistory, summary,
isSessionActive, status, busy, scenarioSwitching,
locale, tr,
Expand All @@ -877,7 +902,7 @@ function AppInner({ children }: { children?: React.ReactNode }) {
audioUrl, api,
startSession, endSession, playCorrection, selectScenario, setLocale, submitText: submitTurn,
clearAudio,
}), [applyTtsPreferences, auth, clearAudio, getAuthHeaders, handleUnauthorized, signOut, snapshot, messages, correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, ttsProvider, ttsVoiceId, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion, audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitTurn, playbackQueue, setSelectedAccentKey, setTtsProvider, setTtsVoiceId, setTtsSpeed])
}), [applyTtsPreferences, auth, availableScenarios, clearAudio, getAuthHeaders, handleUnauthorized, signOut, snapshot, messages, correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, ttsProvider, ttsVoiceId, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion, audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitTurn, playbackQueue, setSelectedAccentKey, setTtsProvider, setTtsVoiceId, setTtsSpeed])

// ─── Tab Selection / 标签选择 ───
const selectTab = useCallback((tab: Tab) => {
Expand Down
Loading
Loading