merge dark mode and google OAuth (#35)

* feat(api): implement Google OAuth login and user management

- Added Google OpenID Connect login functionality, allowing users to authenticate using their Google accounts.
- Created new endpoints for Google login, including user registration and linking existing accounts.
- Introduced Google token verification logic and error handling for authentication failures.
- Updated environment configuration to include Google OAuth client IDs and verification settings.
- Enhanced user model to support OpenID and linked Google accounts.

This feature improves user experience by enabling seamless sign-in with Google, while maintaining security and integrity of user data.

* fix(auth): wire staging Google token verifier

* chore(deps): update expo to version 55.0.6 and adjust @expo/env dependency in pnpm-lock.yaml

* chore(deps): update Babel dependencies to version 7.29.7 in package-lock.json

* feat(auth): enhance phone login for China users

- Updated phone login functionality to support only mainland China (+86) mobile numbers.
- Added user prompts and descriptions for phone login, including confirmation and cancellation options.
- Adjusted translations for both English and Chinese to reflect the new phone login requirements.
- Updated Google OAuth client IDs in configuration files for production and staging environments.

* chore(deps): add peer flag to use-sync-external-store in package-lock.json

* chore(deps): add @emnapi/core and @emnapi/runtime to package-lock.json

* fix(app-expo): align Android native dependencies

* fix(app-expo): normalize lockfile for npm 10

* fix(config): update environment variable handling to use static access

- Introduced a static mapping for public environment variables to ensure proper access during the release bundle.
- Updated the `requirePublicEnv` and `optionalPublicEnv` functions to reference the new `PUBLIC_ENV` object instead of directly accessing `process.env`.
- Added comments to clarify the necessity of static access for certain environment variables.

* feat(app-expo): dark mode, FAQ i18n, eval ASR, and theme cleanup (#34)

* feat(app-expo): dark mode, FAQ i18n, version CI, and theme cleanup

Implement light/dark scene colors across chat, reading, and headers; remove
default/brand theme picker and ThemeVariablesProvider. Localize FAQ in-app,
fix dark-mode text visibility, and remove the unused /api/faqs endpoint.
Align About/version with Expo config and inject APP_VERSION in CI builds.

Also includes phone E164 auth/SMS updates, eval ASR page, and related API work.

* revert: remove phone E.164 changes from dark-mode branch

These auth/SMS internationalization updates were accidentally bundled into
the dark-mode commit; restore 11-digit CN phone flow and drop related API,
migration, and Expo UI work from this branch.

* fix: address PR review issues for dark mode and eval ASR

Use light foreground colors for sepia reading in dark mode, fix chat send
button contrast, stream-limit eval ASR uploads, restore LiveTester phone
validation, and remove unused AudioSegmenter code.

* fix(app-expo): improve chat send button contrast in light and dark mode

Add dedicated send button colors (accent fill in dark, primary fill in
light), use RNText to avoid NativeWind overrides, and restore dark labels
in light mode for readable composer actions.

---------

Co-authored-by: Kevin <kevin@brighteng.org>

---------

Co-authored-by: penghanyuan <penghanyuan@gmail.com>
Co-authored-by: Kevin <kevin@brighteng.org>
This commit is contained in:
Sully
2026-06-09 11:14:36 +08:00
committed by GitHub
parent 10d9e13f14
commit 105b50a277
105 changed files with 22363 additions and 6105 deletions

View File

@@ -11,6 +11,7 @@ import MemoirPage from "./pages/MemoirPage";
import MemoirStoriesPage from "./pages/MemoirStoriesPage";
import PlaygroundPage from "./pages/PlaygroundPage";
import LiveTesterPage from "./pages/LiveTesterPage";
import AsrPage from "./pages/AsrPage";
function RouteOutlet({ route }: { route: AppRoute }) {
switch (route) {
@@ -22,6 +23,8 @@ function RouteOutlet({ route }: { route: AppRoute }) {
return <MemoirStoriesPage />;
case "live":
return <LiveTesterPage />;
case "asr":
return <AsrPage />;
default:
return <PlaygroundPage />;
}

View File

@@ -55,3 +55,59 @@ export async function api<T>(
};
}
}
/** multipart 上传(勿设 Content-Type由浏览器写入 boundary。 */
export async function apiMultipart<T>(
path: string,
formData: FormData,
init?: Omit<RequestInit, "body" | "method">,
): Promise<{
ok: boolean;
data?: T;
error?: string;
errorCode?: string;
requestId?: string;
status: number;
}> {
const url = `${apiBase}${path.startsWith("/") ? path : `/${path}`}`;
try {
const r = await fetch(url, {
...init,
method: "POST",
headers: {
"X-Internal-Eval-Key": apiKey,
...(init?.headers ?? {}),
},
body: formData,
signal: init?.signal,
});
const text = await r.text();
let data: T | undefined;
try {
data = text ? (JSON.parse(text) as T) : undefined;
} catch {
/* ignore */
}
if (!r.ok) {
const parsed = parseApiError(data, text || r.statusText);
return {
ok: false,
status: r.status,
error: parsed.message,
errorCode: parsed.errorCode,
requestId: parsed.requestId,
};
}
return { ok: true, data, status: r.status };
} catch (e: unknown) {
const name = e instanceof Error ? e.name : "";
if (name === "AbortError") {
return { ok: false, status: 0, error: "aborted" };
}
return {
ok: false,
status: 0,
error: e instanceof Error ? e.message : "network error",
};
}
}

View File

@@ -5,6 +5,7 @@ const NAV: { route: AppRoute; label: string; sub?: string }[] = [
{ route: "memoir", label: "Memoir", sub: "章节对照" },
{ route: "memoir-stories", label: "Stories", sub: "故事成稿" },
{ route: "live", label: "实机联调", sub: "主站聊天/回忆录" },
{ route: "asr", label: "ASR", sub: "语音转写" },
];
export function Sidebar({

View File

@@ -2367,3 +2367,79 @@ code {
overflow: auto;
max-height: 40vh;
}
/* ASR transcribe page */
.eval-asr-page .eval-asr-file {
min-width: min(100%, 320px);
}
.eval-asr-record-field {
min-width: min(100%, 280px);
}
.eval-asr-record-controls {
min-height: 2.25rem;
align-items: center;
}
.eval-asr-recording-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: var(--danger-text);
animation: eval-asr-pulse 1.1s ease-in-out infinite;
}
@keyframes eval-asr-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.eval-asr-recording-time {
font-variant-numeric: tabular-nums;
font-size: var(--text-sm);
}
.eval-asr-preview {
max-height: none;
}
.eval-asr-audio {
width: 100%;
}
.eval-asr-result {
min-height: 12rem;
}
.eval-asr-result__header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--s-3);
}
.eval-asr-transcript {
white-space: pre-wrap;
line-height: 1.65;
font-size: var(--text-body);
padding: var(--s-3);
border-radius: var(--r-md);
border: 1px solid var(--border);
background: var(--bg);
min-height: 6rem;
overflow-wrap: anywhere;
user-select: text;
}
.eval-asr-transcript-empty {
margin: 0;
padding: var(--s-3);
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { AppRoute } from "../types";
const ROUTES: AppRoute[] = ["playground", "memoir", "memoir-stories", "live"];
const ROUTES: AppRoute[] = ["playground", "memoir", "memoir-stories", "live", "asr"];
function parseHash(): AppRoute {
const raw = window.location.hash.slice(1).split("?")[0] || "playground";

View File

@@ -0,0 +1,341 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { apiMultipart } from "../api";
import { CopyTextButton } from "../components/CopyTextButton";
import { EmptyState } from "../components/EmptyState";
import { usePushNotice } from "../context/NoticeContext";
import {
convertBlobToWav16kMono,
formatBytes,
formatDurationMs,
pickRecorderMimeType,
} from "../utils/audioToWav16k";
type AsrTranscribeResponse = {
text: string;
format: string;
audio_bytes: number;
};
type RecordPhase = "idle" | "recording" | "recorded";
export default function AsrPage() {
const pushNotice = usePushNotice();
const fileInputRef = useRef<HTMLInputElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordChunksRef = useRef<Blob[]>([]);
const recordStartedAtRef = useRef<number>(0);
const streamRef = useRef<MediaStream | null>(null);
const audioUrlRef = useRef<string | null>(null);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioLabel, setAudioLabel] = useState("");
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [voiceFormat, setVoiceFormat] = useState<string | null>(null);
const [recordPhase, setRecordPhase] = useState<RecordPhase>("idle");
const [recordElapsedMs, setRecordElapsedMs] = useState(0);
const [transcript, setTranscript] = useState("");
const [busy, setBusy] = useState(false);
const [lastMeta, setLastMeta] = useState<string | null>(null);
const revokeAudioUrl = useCallback(() => {
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
audioUrlRef.current = null;
}
}, []);
const setPreviewBlob = useCallback(
(blob: Blob, label: string, format: string | null) => {
revokeAudioUrl();
const url = URL.createObjectURL(blob);
audioUrlRef.current = url;
setAudioBlob(blob);
setAudioLabel(label);
setAudioUrl(url);
setVoiceFormat(format);
setTranscript("");
setLastMeta(null);
},
[revokeAudioUrl],
);
useEffect(() => {
return () => {
revokeAudioUrl();
streamRef.current?.getTracks().forEach((t) => t.stop());
};
}, [revokeAudioUrl]);
useEffect(() => {
if (recordPhase !== "recording") return;
const id = window.setInterval(() => {
setRecordElapsedMs(Date.now() - recordStartedAtRef.current);
}, 200);
return () => window.clearInterval(id);
}, [recordPhase]);
const stopStream = () => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
const onFileChange = async (file: File | null) => {
if (!file) return;
const ext = file.name.includes(".")
? file.name.split(".").pop()?.toLowerCase()
: "";
const formatHint =
ext === "wav"
? "wav"
: ext === "mp3"
? "mp3"
: ext === "m4a" || ext === "mp4"
? "m4a"
: ext === "aac"
? "aac"
: ext === "webm" || ext === "ogg"
? "ogg-opus"
: null;
setPreviewBlob(file, file.name, formatHint);
pushNotice(`已选择 ${file.name}${formatBytes(file.size)}`, "success");
};
const startRecording = async () => {
if (!navigator.mediaDevices?.getUserMedia) {
pushNotice("当前浏览器不支持麦克风录音", "error");
return;
}
const mimeType = pickRecorderMimeType();
if (!mimeType) {
pushNotice("当前浏览器不支持 MediaRecorder", "error");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
recordChunksRef.current = [];
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) recordChunksRef.current.push(ev.data);
};
recorder.onstop = () => {
stopStream();
void (async () => {
const raw = new Blob(recordChunksRef.current, { type: mimeType });
if (!raw.size) {
pushNotice("录音为空,请重试", "error");
setRecordPhase("idle");
return;
}
try {
const wav = await convertBlobToWav16kMono(raw);
const durationMs = Date.now() - recordStartedAtRef.current;
setPreviewBlob(
wav,
`recording-${new Date().toISOString().slice(11, 19)}.wav`,
"wav",
);
setRecordPhase("recorded");
pushNotice(
`录音完成 ${formatDurationMs(durationMs)},已转为 16kHz WAV`,
"success",
);
} catch (e) {
pushNotice(
e instanceof Error ? e.message : "录音转码失败",
"error",
);
setRecordPhase("idle");
}
})();
};
recordStartedAtRef.current = Date.now();
setRecordElapsedMs(0);
setRecordPhase("recording");
recorder.start(250);
} catch (e) {
stopStream();
pushNotice(
e instanceof Error ? e.message : "无法访问麦克风",
"error",
);
}
};
const stopRecording = () => {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== "inactive") {
recorder.stop();
}
mediaRecorderRef.current = null;
};
const transcribe = async () => {
if (!audioBlob) {
pushNotice("请先上传或录制音频", "error");
return;
}
setBusy(true);
setTranscript("");
setLastMeta(null);
try {
const form = new FormData();
const filename = audioLabel || "audio.wav";
form.append("file", audioBlob, filename);
const path =
voiceFormat != null
? `/internal/api/evaluation/asr/transcribe?format=${encodeURIComponent(voiceFormat)}`
: "/internal/api/evaluation/asr/transcribe";
const r = await apiMultipart<AsrTranscribeResponse>(path, form);
if (!r.ok) {
pushNotice(r.error ?? "转写失败", "error");
return;
}
const data = r.data!;
setTranscript(data.text);
setLastMeta(
`format=${data.format} · ${formatBytes(data.audio_bytes)} · ${data.text.length}`,
);
pushNotice("转写完成", "success");
} finally {
setBusy(false);
}
};
const clearAudio = () => {
if (recordPhase === "recording") stopRecording();
revokeAudioUrl();
setAudioBlob(null);
setAudioLabel("");
setAudioUrl(null);
setVoiceFormat(null);
setRecordPhase("idle");
setRecordElapsedMs(0);
setTranscript("");
setLastMeta(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
return (
<main className="eval-main eval-main--memoir eval-asr-page">
<h1 className="eval-page-title">ASR · </h1>
<p className="eval-page-intro eval-page-intro--memoir">
使 ASR
<code className="eval-mono-inline">16k_zh_large</code>
16kHz WAV m4a / mp3 / wav
</p>
<div className="eval-memoir-toolbar">
<div className="eval-memoir-toolbar__primary">
<div className="eval-memoir-field">
<span className="eval-label eval-label--muted"></span>
<div className="eval-memoir-field__control">
<input
ref={fileInputRef}
type="file"
className="eval-input eval-asr-file"
accept="audio/*,.m4a,.mp3,.wav,.aac,.webm,.ogg"
onChange={(e) => void onFileChange(e.target.files?.[0] ?? null)}
aria-label="选择音频文件"
/>
</div>
</div>
<div className="eval-memoir-field eval-asr-record-field">
<span className="eval-label eval-label--muted"></span>
<div className="eval-memoir-field__control eval-asr-record-controls">
{recordPhase === "recording" ? (
<>
<span className="eval-asr-recording-dot" aria-hidden />
<span className="eval-muted eval-asr-recording-time">
{formatDurationMs(recordElapsedMs)}
</span>
<button
type="button"
className="eval-btn eval-btn--danger"
onClick={stopRecording}
>
</button>
</>
) : (
<button
type="button"
className="eval-btn eval-btn--primary"
disabled={busy}
onClick={() => void startRecording()}
>
</button>
)}
</div>
</div>
</div>
<div className="eval-memoir-toolbar__actions">
<button
type="button"
className="eval-btn eval-btn--primary"
disabled={busy || !audioBlob}
onClick={() => void transcribe()}
>
{busy ? "转写中…" : "开始转写"}
</button>
<button
type="button"
className="eval-btn eval-btn--ghost"
disabled={busy && !audioBlob}
onClick={clearAudio}
>
</button>
</div>
</div>
{audioUrl ? (
<section className="eval-memoir-panel eval-asr-preview">
<div className="eval-memoir-panel__header">
<h2 className="eval-memoir-panel__title"></h2>
<p className="eval-memoir-panel__sub eval-muted">
{audioLabel}
{voiceFormat ? ` · ${voiceFormat}` : ""}
{audioBlob ? ` · ${formatBytes(audioBlob.size)}` : ""}
</p>
</div>
<audio className="eval-asr-audio" controls src={audioUrl}>
audio
</audio>
</section>
) : (
<EmptyState
title="尚未选择音频"
description="上传文件或点击「开始录音」,然后点「开始转写」。"
/>
)}
<section className="eval-memoir-panel eval-memoir-panel--full eval-asr-result">
<div className="eval-memoir-panel__header eval-asr-result__header">
<div>
<h2 className="eval-memoir-panel__title"></h2>
{lastMeta ? (
<p className="eval-memoir-panel__sub eval-muted">{lastMeta}</p>
) : (
<p className="eval-memoir-panel__sub eval-muted">
</p>
)}
</div>
{transcript ? (
<CopyTextButton text={transcript} label="复制全文" />
) : null}
</div>
{transcript ? (
<div className="eval-asr-transcript">{transcript}</div>
) : (
<p className="eval-muted eval-asr-transcript-empty">
{busy ? "正在调用 ASR…" : "暂无转写文本"}
</p>
)}
</section>
</main>
);
}

View File

@@ -27,4 +27,4 @@ export type FixtureDetailResponse = {
memoir_sections?: { title: string; body: string }[];
};
export type AppRoute = "playground" | "memoir" | "memoir-stories" | "live";
export type AppRoute = "playground" | "memoir" | "memoir-stories" | "live" | "asr";

View File

@@ -0,0 +1,119 @@
/** 将浏览器录制的音频转为 16kHz 单声道 WAV匹配腾讯云 16k_zh_large 引擎。 */
export async function convertBlobToWav16kMono(blob: Blob): Promise<Blob> {
const arrayBuffer = await blob.arrayBuffer();
const decodeCtx = new AudioContext();
let decoded: AudioBuffer;
try {
decoded = await decodeCtx.decodeAudioData(arrayBuffer.slice(0));
} finally {
await decodeCtx.close();
}
const mono = mixToMono(decoded);
const monoBuffer = new AudioBuffer({
length: mono.length,
numberOfChannels: 1,
sampleRate: decoded.sampleRate,
});
monoBuffer.copyToChannel(mono, 0);
const targetRate = 16000;
const frames = Math.max(1, Math.ceil(monoBuffer.duration * targetRate));
const offline = new OfflineAudioContext(1, frames, targetRate);
const source = offline.createBufferSource();
source.buffer = monoBuffer;
source.connect(offline.destination);
source.start();
const rendered = await offline.startRendering();
return encodeWav(rendered);
}
function mixToMono(buffer: AudioBuffer): Float32Array {
const len = buffer.length;
const channels = buffer.numberOfChannels;
const out = new Float32Array(len);
if (channels === 1) {
out.set(buffer.getChannelData(0));
return out;
}
for (let i = 0; i < len; i++) {
let sum = 0;
for (let c = 0; c < channels; c++) {
sum += buffer.getChannelData(c)[i] ?? 0;
}
out[i] = sum / channels;
}
return out;
}
function encodeWav(buffer: AudioBuffer): Blob {
const channel = buffer.getChannelData(0);
const sampleRate = buffer.sampleRate;
const bytesPerSample = 2;
const blockAlign = bytesPerSample;
const dataSize = channel.length * bytesPerSample;
const headerSize = 44;
const arrayBuffer = new ArrayBuffer(headerSize + dataSize);
const view = new DataView(arrayBuffer);
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeString(view, 8, "WAVE");
writeString(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bytesPerSample * 8, true);
writeString(view, 36, "data");
view.setUint32(40, dataSize, true);
let offset = headerSize;
for (let i = 0; i < channel.length; i++) {
const sample = Math.max(-1, Math.min(1, channel[i] ?? 0));
view.setInt16(
offset,
sample < 0 ? sample * 0x8000 : sample * 0x7fff,
true,
);
offset += 2;
}
return new Blob([arrayBuffer], { type: "audio/wav" });
}
function writeString(view: DataView, offset: number, text: string) {
for (let i = 0; i < text.length; i++) {
view.setUint8(offset + i, text.charCodeAt(i));
}
}
export function pickRecorderMimeType(): string | undefined {
const candidates = [
"audio/mp4",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
];
for (const type of candidates) {
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(type)) {
return type;
}
}
return undefined;
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDurationMs(ms: number): string {
const totalSec = Math.max(0, Math.round(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}