diff --git a/.env.example b/.env.example index dc1d023..b570d0f 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ PORT=8787 # SERVER_ROLE=primary # RELAY_NODE_ID=jp-primary # RELAY_PRIMARY_ORIGIN=https://app.kibotalk.app -# RELAY_CN_ORIGIN=https://cn-api.kibotalk.app:8443 +# RELAY_CN_ORIGIN=http://123.99.200.156:8443 # RELAY_CN_NODE_ID=cn-relay # RELAY_CN_ENABLED=true # @@ -24,10 +24,7 @@ PORT=8787 # RELAY_PRIMARY_ORIGIN=https://app.kibotalk.app # RELAY_OUTBOX_PATH=/app/data/usage-outbox.json # RELAY_ACCEPT_NEW_SESSIONS=true -# RELAY_DOMAIN=cn-api.kibotalk.app -# RELAY_HTTPS_PORT=8443 -# ACME_EMAIL=admin@kibotalk.app -# CLOUDFLARE_API_TOKEN=replace-with-dns-edit-token +# RELAY_HTTP_PORT=8443 # # Ed25519:日本保存私钥,两台都保存公钥。值可以是 PEM,或 PEM 的 base64。 # RELAY_TOKEN_PRIVATE_KEY=replace-on-primary-only diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index fecb01e..71b0f8e 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -27,12 +27,10 @@ jobs: CN_SERVER_HOST: ${{ secrets.CN_SERVER_HOST }} CN_SERVER_USER: ${{ secrets.CN_SERVER_USER }} CN_RELAY_ORIGIN: ${{ secrets.CN_RELAY_ORIGIN }} - CN_CLOUDFLARE_API_TOKEN: ${{ secrets.CN_CLOUDFLARE_API_TOKEN }} run: | for secret_name in \ DEPLOY_SSH_KEY SERVER_HOST SERVER_USER \ - CN_DEPLOY_SSH_KEY CN_SERVER_HOST CN_SERVER_USER CN_RELAY_ORIGIN \ - CN_CLOUDFLARE_API_TOKEN; do + CN_DEPLOY_SSH_KEY CN_SERVER_HOST CN_SERVER_USER CN_RELAY_ORIGIN; do if [ -z "${!secret_name}" ]; then echo "::error::Missing Actions secret: ${secret_name}" exit 1 @@ -51,25 +49,27 @@ jobs: chmod 600 ~/.ssh/id_ed25519_primary ~/.ssh/id_ed25519_cn ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts ssh-keyscan -H "$CN_SERVER_HOST" >> ~/.ssh/known_hosts - - name: Configure relay DNS credential + - name: Configure relay origin on primary env: - CN_SERVER_HOST: ${{ secrets.CN_SERVER_HOST }} - CN_SERVER_USER: ${{ secrets.CN_SERVER_USER }} - CN_CLOUDFLARE_API_TOKEN: ${{ secrets.CN_CLOUDFLARE_API_TOKEN }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_USER: ${{ secrets.SERVER_USER }} + CN_RELAY_ORIGIN: ${{ secrets.CN_RELAY_ORIGIN }} run: | - printf '%s\n' "$CN_CLOUDFLARE_API_TOKEN" \ - | ssh -i ~/.ssh/id_ed25519_cn "${CN_SERVER_USER}@${CN_SERVER_HOST}" ' + case "$CN_RELAY_ORIGIN" in + http://*|https://*) ;; + *) echo "::error::CN_RELAY_ORIGIN must be an absolute HTTP(S) origin"; exit 1 ;; + esac + printf '%s\n' "$CN_RELAY_ORIGIN" \ + | ssh -i ~/.ssh/id_ed25519_primary "${SERVER_USER}@${SERVER_HOST}" ' set -eu - IFS= read -r token - test -n "$token" - cd /opt/kibotalk-relay - if grep -q "^CLOUDFLARE_API_TOKEN=" .env; then - escaped=$(printf "%s" "$token" | sed "s/[|&]/\\\\&/g") - sed -i "s|^CLOUDFLARE_API_TOKEN=.*|CLOUDFLARE_API_TOKEN=${escaped}|" .env + IFS= read -r origin + escaped=$(printf "%s" "$origin" | sed "s/[|&]/\\\\&/g") + if grep -q "^RELAY_CN_ORIGIN=" /opt/kibotalk/.env; then + sed -i "s|^RELAY_CN_ORIGIN=.*|RELAY_CN_ORIGIN=${escaped}|" /opt/kibotalk/.env else - printf "CLOUDFLARE_API_TOKEN=%s\n" "$token" >> .env + printf "RELAY_CN_ORIGIN=%s\n" "$origin" >> /opt/kibotalk/.env fi - chmod 600 .env + chmod 600 /opt/kibotalk/.env ' - name: Validate server environments env: @@ -92,16 +92,16 @@ jobs: set -eu test -s /opt/kibotalk-relay/.env for name in \ - RELAY_PRIMARY_ORIGIN RELAY_ACCEPT_NEW_SESSIONS RELAY_DOMAIN \ - RELAY_HTTPS_PORT RELAY_TOKEN_PUBLIC_KEY RELAY_NODE_SECRET \ - ACME_EMAIL CLOUDFLARE_API_TOKEN \ + RELAY_PRIMARY_ORIGIN RELAY_ACCEPT_NEW_SESSIONS \ + RELAY_TOKEN_PUBLIC_KEY RELAY_NODE_SECRET \ STT_ACTIVE STT_BATCH_ACTIVE LLM_ACTIVE; do grep -q "^${name}=." /opt/kibotalk-relay/.env done - relay_port=$(sed -n "s/^RELAY_HTTPS_PORT=//p" /opt/kibotalk-relay/.env) + relay_port=$(sed -n "s/^RELAY_HTTP_PORT=//p" /opt/kibotalk-relay/.env) + relay_port=${relay_port:-8443} case "$relay_port" in 8443|9443) ;; - *) echo "RELAY_HTTPS_PORT must be 8443 or 9443" >&2; exit 1 ;; + *) echo "RELAY_HTTP_PORT must be 8443 or 9443" >&2; exit 1 ;; esac ' - uses: pnpm/action-setup@v4 @@ -119,8 +119,9 @@ jobs: - name: Build API image run: | docker build --platform linux/amd64 --tag "kibotalk-api:${GITHUB_SHA}" . + docker build --platform linux/amd64 --target relay \ + --tag "kibotalk-relay-api:${GITHUB_SHA}" . docker build --platform linux/amd64 --tag kibotalk-caddy:2.10 --file infra/production/Caddy.Dockerfile . - docker build --platform linux/amd64 --tag kibotalk-relay-caddy:2.10 --file infra/relay/Caddy.Dockerfile . docker build --platform linux/amd64 --tag kibotalk-postgres:17 --file infra/production/Postgres.Dockerfile . touch infra/production/.env infra/relay/.env ( @@ -130,7 +131,7 @@ jobs: ) ( cd infra/relay - RELAY_HTTPS_PORT=8443 docker compose -f compose.yaml \ + RELAY_HTTP_PORT=8443 docker compose -f compose.yaml \ config --quiet --no-env-resolution --no-path-resolution ) rm infra/production/.env infra/relay/.env @@ -138,23 +139,13 @@ jobs: --volume "$PWD/infra/production/Caddyfile:/etc/caddy/Caddyfile:ro" \ kibotalk-caddy:2.10 \ caddy validate --config /etc/caddy/Caddyfile - docker run --rm \ - --env ACME_EMAIL=validation@kibotalk.app \ - --env CLOUDFLARE_API_TOKEN=0000000000000000000000000000000000000000 \ - --env RELAY_DOMAIN=relay-validation.kibotalk.app \ - --env RELAY_HTTPS_PORT=8443 \ - --volume "$PWD/infra/relay/Caddyfile:/etc/caddy/Caddyfile:ro" \ - kibotalk-relay-caddy:2.10 \ - caddy validate --config /etc/caddy/Caddyfile docker save \ "kibotalk-api:${GITHUB_SHA}" \ kibotalk-caddy:2.10 \ - kibotalk-relay-caddy:2.10 \ kibotalk-postgres:17 \ | gzip -1 > /tmp/kibotalk-images.tar.gz docker save \ - "kibotalk-api:${GITHUB_SHA}" \ - kibotalk-relay-caddy:2.10 \ + "kibotalk-relay-api:${GITHUB_SHA}" \ | gzip -1 > /tmp/kibotalk-relay-images.tar.gz - name: Stage on-device model mirror run: | @@ -167,69 +158,19 @@ jobs: run: | ssh -i ~/.ssh/id_ed25519_primary "${SERVER_USER}@${SERVER_HOST}" "mkdir -p /opt/kibotalk/incoming" scp -i ~/.ssh/id_ed25519_primary /tmp/kibotalk-images.tar.gz "${SERVER_USER}@${SERVER_HOST}:/opt/kibotalk/incoming/kibotalk-images.tar.gz" - scp -i ~/.ssh/id_ed25519_primary /tmp/kibotalk-relay-images.tar.gz "${SERVER_USER}@${SERVER_HOST}:/opt/kibotalk/incoming/kibotalk-relay-images.tar.gz" scp -i ~/.ssh/id_ed25519_primary /tmp/kibotalk-models.tar.gz "${SERVER_USER}@${SERVER_HOST}:/opt/kibotalk/incoming/kibotalk-models.tar.gz" scp -i ~/.ssh/id_ed25519_primary infra/production/compose.yaml infra/production/Caddyfile "${SERVER_USER}@${SERVER_HOST}:/opt/kibotalk/" - name: Upload relay payload env: - SERVER_HOST: ${{ secrets.SERVER_HOST }} - SERVER_USER: ${{ secrets.SERVER_USER }} CN_SERVER_HOST: ${{ secrets.CN_SERVER_HOST }} CN_SERVER_USER: ${{ secrets.CN_SERVER_USER }} run: | ssh -i ~/.ssh/id_ed25519_cn "${CN_SERVER_USER}@${CN_SERVER_HOST}" \ - "mkdir -p /opt/kibotalk-relay/incoming/parts-${GITHUB_SHA}" - ssh -i ~/.ssh/id_ed25519_primary "${SERVER_USER}@${SERVER_HOST}" \ - "mkdir -p /opt/kibotalk/incoming/relay-parts-${GITHUB_SHA} \ - && split --number=8 --numeric-suffixes=0 --suffix-length=3 \ - /opt/kibotalk/incoming/kibotalk-relay-images.tar.gz \ - /opt/kibotalk/incoming/relay-parts-${GITHUB_SHA}/part-" - eval "$(ssh-agent -s)" - trap 'ssh-agent -k >/dev/null' EXIT - ssh-add ~/.ssh/id_ed25519_cn - ssh -A -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_primary \ - "${SERVER_USER}@${SERVER_HOST}" \ - bash -s -- "$CN_SERVER_USER" "$CN_SERVER_HOST" "$GITHUB_SHA" <<'REMOTE' - set -eu - cn_user=$1 - cn_host=$2 - release_sha=$3 - part_dir="/opt/kibotalk/incoming/relay-parts-${release_sha}" - pids="" - for part in "${part_dir}"/part-*; do - scp -q -o StrictHostKeyChecking=accept-new \ - "$part" \ - "${cn_user}@${cn_host}:/opt/kibotalk-relay/incoming/parts-${release_sha}/" & - pids="${pids} $!" - done - transfer_status=0 - for pid in $pids; do - wait "$pid" || transfer_status=1 - done - test "$transfer_status" -eq 0 - REMOTE - ssh -i ~/.ssh/id_ed25519_cn "${CN_SERVER_USER}@${CN_SERVER_HOST}" \ - bash -s -- "$GITHUB_SHA" <<'REMOTE' - set -eu - release_sha=$1 - part_dir="/opt/kibotalk-relay/incoming/parts-${release_sha}" - cat "${part_dir}"/part-* > /opt/kibotalk-relay/incoming/kibotalk-images.tar.gz - gzip -t /opt/kibotalk-relay/incoming/kibotalk-images.tar.gz - for part in "${part_dir}"/part-*; do - unlink "$part" - done - rmdir "$part_dir" - REMOTE - ssh -i ~/.ssh/id_ed25519_primary "${SERVER_USER}@${SERVER_HOST}" \ - bash -s -- "$GITHUB_SHA" <<'REMOTE' - set -eu - part_dir="/opt/kibotalk/incoming/relay-parts-${1}" - for part in "${part_dir}"/part-*; do - unlink "$part" - done - rmdir "$part_dir" - REMOTE - scp -i ~/.ssh/id_ed25519_cn infra/relay/compose.yaml infra/relay/Caddyfile "${CN_SERVER_USER}@${CN_SERVER_HOST}:/opt/kibotalk-relay/" + "mkdir -p /opt/kibotalk-relay/incoming" + scp -i ~/.ssh/id_ed25519_cn /tmp/kibotalk-relay-images.tar.gz \ + "${CN_SERVER_USER}@${CN_SERVER_HOST}:/opt/kibotalk-relay/incoming/kibotalk-images.tar.gz" + scp -i ~/.ssh/id_ed25519_cn infra/relay/compose.yaml \ + "${CN_SERVER_USER}@${CN_SERVER_HOST}:/opt/kibotalk-relay/" - name: Activate release env: SERVER_HOST: ${{ secrets.SERVER_HOST }} @@ -268,14 +209,14 @@ jobs: set -eu gzip -dc /opt/kibotalk-relay/incoming/kibotalk-images.tar.gz | docker load cd /opt/kibotalk-relay - if grep -q '^KIBOTALK_API_IMAGE=' .env; then - sed -i 's|^KIBOTALK_API_IMAGE=.*|KIBOTALK_API_IMAGE=kibotalk-api:${GITHUB_SHA}|' .env + if grep -q '^KIBOTALK_RELAY_IMAGE=' .env; then + sed -i 's|^KIBOTALK_RELAY_IMAGE=.*|KIBOTALK_RELAY_IMAGE=kibotalk-relay-api:${GITHUB_SHA}|' .env else - printf '\nKIBOTALK_API_IMAGE=kibotalk-api:${GITHUB_SHA}\n' >> .env + printf '\nKIBOTALK_RELAY_IMAGE=kibotalk-relay-api:${GITHUB_SHA}\n' >> .env fi docker compose --env-file .env -f compose.yaml up -d --pull never " - - name: Verify HTTPS + - name: Verify deployment env: CN_RELAY_ORIGIN: ${{ secrets.CN_RELAY_ORIGIN }} run: | diff --git a/Dockerfile b/Dockerfile index 71c258e..bcdef25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,15 +45,22 @@ RUN pnpm --filter @kibotalk/web build \ && mkdir -p /output/web \ && cp -R apps/web/dist /output/web/dist -FROM node:22-bookworm-slim AS runtime +FROM node:22-alpine AS runtime-base ENV NODE_ENV=production WORKDIR /app/api -COPY --from=builder /output/api /app/api -COPY --from=builder /output/web /app/web USER root RUN mkdir -p /app/data && chown node:node /app/data USER node + +FROM runtime-base AS relay + +COPY --from=builder /output/api /app/api + EXPOSE 8787 CMD ["node", "dist/index.cjs"] + +FROM relay AS runtime + +COPY --from=builder /output/web /app/web diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d85bda0..4f11375 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -520,13 +520,15 @@ app.post('/api/session-review', async (context) => { }) // Legacy aliases keep the local playground/tests working while product clients -// use the namespaced routes. Production Caddy exposes both, but auth remains -// enforced because aliases re-enter the same handlers. +// use the namespaced routes. Auth remains enforced because aliases re-enter +// the same handlers. app.route('/', new Hono() .get('/stt/providers', (context) => app.fetch(new Request(new URL('/api/stt/providers', context.req.url), context.req.raw))) .post('/stt', (context) => app.fetch(new Request(new URL(`/api/stt${new URL(context.req.url).search}`, context.req.url), context.req.raw))) .post('/llm', (context) => app.fetch(new Request(new URL('/api/llm', context.req.url), context.req.raw))) .post('/session-review', (context) => app.fetch(new Request(new URL('/api/session-review', context.req.url), context.req.raw)))) -app.use('/*', serveStatic({ root: '../web/dist' })) -app.get('*', serveStatic({ root: '../web/dist', path: './index.html' })) +if (role === 'primary') { + app.use('/*', serveStatic({ root: '../web/dist' })) + app.get('*', serveStatic({ root: '../web/dist', path: './index.html' })) +} diff --git a/apps/api/src/relay-nodes.ts b/apps/api/src/relay-nodes.ts index 531f270..1578e54 100644 --- a/apps/api/src/relay-nodes.ts +++ b/apps/api/src/relay-nodes.ts @@ -11,7 +11,6 @@ import { const DEFAULT_PROBE = { attempts: 5, timeoutMs: 1_500, - primaryTieThresholdMs: 5, } as const const HEARTBEAT_MAX_AGE_SECONDS = 45 diff --git a/apps/api/test/relay-routes.test.ts b/apps/api/test/relay-routes.test.ts index 2178663..8c2353a 100644 --- a/apps/api/test/relay-routes.test.ts +++ b/apps/api/test/relay-routes.test.ts @@ -21,7 +21,7 @@ afterEach(() => { describe('relay control plane', () => { it('publishes both configured nodes and signs a node-bound session grant', async () => { process.env.RELAY_PRIMARY_ORIGIN = 'https://app.kibotalk.app' - process.env.RELAY_CN_ORIGIN = 'https://cn-api.kibotalk.app:8443' + process.env.RELAY_CN_ORIGIN = 'http://123.99.200.156:8443' process.env.RELAY_CN_ENABLED = 'true' process.env.STT_ACTIVE = 'dashscope-realtime' process.env.LLM_ACTIVE = 'openrouter' @@ -31,16 +31,16 @@ describe('relay control plane', () => { expect(nodesResponse.status).toBe(200) const nodes = await nodesResponse.json() as { nodes: Array<{ id: string; origin: string }> - probe: { attempts: number; primaryTieThresholdMs: number } + probe: { attempts: number } } expect(nodes.nodes).toEqual([ expect.objectContaining({ id: 'jp-primary', origin: 'https://app.kibotalk.app' }), expect.objectContaining({ id: 'cn-relay', - origin: 'https://cn-api.kibotalk.app:8443', + origin: 'http://123.99.200.156:8443', }), ]) - expect(nodes.probe).toMatchObject({ attempts: 5, primaryTieThresholdMs: 5 }) + expect(nodes.probe).toMatchObject({ attempts: 5 }) const grantResponse = await app.request('/api/relay/sessions', { method: 'POST', diff --git a/apps/desktop/src/renderer/IslandApp.tsx b/apps/desktop/src/renderer/IslandApp.tsx index e963bbd..1dc9cfd 100644 --- a/apps/desktop/src/renderer/IslandApp.tsx +++ b/apps/desktop/src/renderer/IslandApp.tsx @@ -53,6 +53,7 @@ function ReadyIsland({ sessionTitle: localizedSessionFallbackTitle(Date.now(), snapshot.conversationLang, language), storage, candidateRoundsMax: 3, + preferredRelayNodeId: prefs.relayNodeId, getSystemAudioStream, stopSystemAudioStream: () => window.kibotalk.systemAudio.stop(), }) @@ -81,7 +82,7 @@ function ReadyIsland({ const current = controllerRef.current switch (command) { case 'start': - void current.session.start() + current.requestSessionStart() break case 'pause': void current.session.pause() diff --git a/apps/playground/src/LiveSession.tsx b/apps/playground/src/LiveSession.tsx index 0607891..9754bf1 100644 --- a/apps/playground/src/LiveSession.tsx +++ b/apps/playground/src/LiveSession.tsx @@ -27,7 +27,9 @@ import { type RealtimeSttClient, type TranscribedAudioSegment, providerMode, + fetchRelayNodes, openRelaySession, + probeRelayNodes, releaseRelaySession, type SttProvider, } from '@kibotalk/app-shared' @@ -284,10 +286,13 @@ export default function LiveSession({ const relaySessionId = globalThis.crypto?.randomUUID?.() ?? `playground-${Date.now()}-${Math.random().toString(36).slice(2)}` - setLoading('正在选择 API 网络节点…') + if (!cfg.relayNodeId) throw new Error('请先选择 API 网络节点') + setLoading('正在连接所选 API 网络节点…') + const relayProbeResults = await probeRelayNodes(await fetchRelayNodes()) const relaySelection = await openRelaySession({ conversationSessionId: relaySessionId, - preferredNodeId: cfg.relayNodeOverride ?? undefined, + nodeId: cfg.relayNodeId, + probeResults: relayProbeResults, }) relaySessionIdRef.current = relaySessionId const relayStatus = diff --git a/apps/playground/src/components/ConfigFields.tsx b/apps/playground/src/components/ConfigFields.tsx index 0e27632..a607647 100644 --- a/apps/playground/src/components/ConfigFields.tsx +++ b/apps/playground/src/components/ConfigFields.tsx @@ -19,8 +19,9 @@ import { } from '../config-store' import { fetchRelayNodes, + probeRelayNodes, SILERO_VARIANTS, - type RelayNode, + type RelayProbeResult, } from '@kibotalk/app-shared' import { SttProviderSelect } from '../SttProviderSelect' import { useTranscribeProvider } from '../SttProviderSelect' @@ -256,20 +257,25 @@ export function TranscribeProviderSelect({ ) } -/** Data-plane node override for playground diagnostics. */ +/** Manual data-plane node selection with measured user-to-node latency. */ export function RelayNodeSelect({ disabled }: { disabled?: boolean }) { - const relayNodeOverride = useConfig((state) => state.relayNodeOverride) + const relayNodeId = useConfig((state) => state.relayNodeId) const patch = useConfig((state) => state.patch) - const [nodes, setNodes] = useState([]) + const [results, setResults] = useState([]) useEffect(() => { let cancelled = false void fetchRelayNodes() - .then((nodeList) => { - if (!cancelled) setNodes(nodeList.nodes) + .then(async (nodeList) => { + const measured = await probeRelayNodes(nodeList) + if (cancelled) return + setResults(measured) + if (!useConfig.getState().relayNodeId) { + patch({ relayNodeId: nodeList.primaryNodeId }) + } }) .catch(() => { - if (!cancelled) setNodes([]) + if (!cancelled) setResults([]) }) return () => { cancelled = true @@ -280,19 +286,20 @@ export function RelayNodeSelect({ disabled }: { disabled?: boolean }) {
+ +
+ + +
+
+ {prefs.relayNodeId === 'cn-relay' ? ( +
+ + {t('insecureRelayWarning')} +
+ ) : null} + {relayProbes.error ? ( +
+ {t('nodeProbeFailed')} +
+ ) : null} {platform === 'desktop' ? ( diff --git a/packages/pages/src/index.ts b/packages/pages/src/index.ts index f7721c8..4a2cf2b 100644 --- a/packages/pages/src/index.ts +++ b/packages/pages/src/index.ts @@ -2,6 +2,7 @@ export { OnboardingPage, type OnboardingPageProps } from './OnboardingPage' export { EnrollmentPage, type EnrollmentPageProps } from './EnrollmentPage' export { SessionPage, type SessionPageProps } from './SessionPage' export { IslandPage, type IslandPageProps } from './IslandPage' +export { RelayNodeDialog } from './RelayNodeDialog' export { SettingsPage, type PermissionState, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7e17f9c..df3ed06 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,7 +13,6 @@ export type RelayNodeList = { probe: { attempts: number timeoutMs: number - primaryTieThresholdMs: number } }