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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ npm run preview

## 功能与验证

主页包含会员、聊天和粤语字幕;`arcade/` 包含游戏大厅、Starfall 和 Frontier。认证和存档使用安全 v2 读取路径,历史和游戏进度按稳定用户 ID 隔离。无归属旧数据保留,不自动认领;存档管理提供备份导出和明确恢复选择。
主页保留原有展示;自 2026-10-07 起,MiMo 登录、聊天和粤语字幕入口显示“维护中”,等待新模型接入。共享会员与游戏存档数据继续保留。`arcade/` 包含游戏大厅、Starfall 和 Frontier。认证和存档使用安全 v2 读取路径,历史和游戏进度按稳定用户 ID 隔离。无归属旧数据保留,不自动认领;存档管理提供备份导出和明确恢复选择。

```bash
npm run check
Expand All @@ -44,6 +44,8 @@ npm run test:browser

浏览器回归使用独立临时环境、合成账号和模拟上游。Linux 优先使用已安装的稳定版 Chrome,CI 明确使用该配置并记录版本;其他环境回退到打包 Chromium。`browser:install` 只安装到 `~/.codex/browser/raypage-playwright`,不使用共享缓存。可用 `CHROMIUM_PATH` 指定已安装的隔离浏览器。禁止使用 Hermes 的浏览器路径。测试不是实际邮件、模型账单、真机麦克风或 Safari 验收。

维护模式回归直接测试发布页面,并对照退役前版本核对主页文字及布局。原认证、媒体生命周期回归仅在测试服务器的响应中关闭维护开关,以保留未启用代码的验证;线上没有查询参数或浏览器存储开关可恢复 AI 服务。

公开仓库只包含前端和测试,不包含生产凭据、用户数据或 Worker 私有源码。后端在本机 `.local-secrets/ray-backend` 独立版本管理,包含迁移、恢复验证和受保护发布工具;该目录不能提交到本仓库或暴露给 HTTP 服务。

- [云存档协议与迁移](docs/game-save-cloud-api.md)
Expand Down
4 changes: 3 additions & 1 deletion docs/release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@

## 数据与失败行为

2026-10-07 MiMo 退役:主页布局保留,登录及 AI 入口显示“维护中”。聊天和粤语转写接口统一返回 `503 service_maintenance`,不读取用户数据、不预占额度、不调用上游。Worker 保留共享账号、管理与游戏存档服务,移除 MiMo 模型、地址及密钥绑定。现有数据库与历史存档保留;未来恢复 AI 服务需要接入并验证新模型,单独打开旧通道开关不能恢复 MiMo。

用户 ID、应用 ID 和 schema 构成本地命名空间。退出未被服务器确认时锁定本机私人界面,不能把断网当成服务器会话失效。已有无归属存档、聊天旧键保留,不自动上传。

存档使用 IndexedDB 事务、本地版本和跨页通知。每个云请求快照固定请求 ID、基准 revision 和校验值;只有精确 ACK 才确认对应快照。版本冲突保留副本,需要用户选择时不得猜测余额或棋盘。

聊天及字幕在上游前预占额度;确定拒绝可以释放,超时或结果不明不自动重放。字幕按服务端验证的 WAV 样本时长计量。普通会员现有 10 次聊天、30 秒字幕额度不在本次升级中更改。
退役前聊天及字幕在上游前预占额度,字幕按服务端验证的 WAV 样本时长计量。历史额度数据保留,但维护接口不再预占或消耗额度。新模型接入时需重新验证计量与失败重试规则,不能直接沿用已退役的处理流程。

## Service Worker

Expand Down
52 changes: 49 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="image" href="assets/ray-eye-portrait.webp?v=cinematic-camera-drift" type="image/webp" fetchpriority="high" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css?v=rev30-letter-spacing-open" />
<link rel="stylesheet" href="styles.css?v=rev31-ai-maintenance" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down Expand Up @@ -454,6 +454,17 @@ <h3>邀请码</h3>
</section>
</div>

<div id="maintenance-modal" class="maintenance-modal" role="dialog" aria-modal="true" aria-labelledby="maintenance-title" aria-describedby="maintenance-description" hidden>
<div class="maintenance-backdrop" data-close-maintenance></div>
<section class="maintenance-panel">
<button class="maintenance-close" type="button" data-close-maintenance aria-label="关闭维护提示">×</button>
<p class="eyebrow">RAY AI</p>
<h2 id="maintenance-title">维护中</h2>
<p id="maintenance-description">AI 服务正在维护,待新模型接入后恢复。</p>
<button class="button secondary" type="button" data-close-maintenance>知道了</button>
</section>
</div>

<footer class="footer">
<span>© <span id="year"></span> Ray</span>
<span>AIAgent Product Explorer / Indie Developer</span>
Expand Down Expand Up @@ -930,6 +941,30 @@ <h3>邀请码</h3>
if ((event.key === 'Enter' || event.key === 'Escape') && document.body.classList.contains('intro-active')) enterMainSite();
});

const AI_MAINTENANCE = true;
const maintenanceModal = document.getElementById('maintenance-modal');
const closeMaintenanceModal = () => {
if (maintenanceModal) maintenanceModal.hidden = true;
document.body.classList.remove('modal-open');
};
const openMaintenanceModal = (opener = document.activeElement) => {
closeChatModal(); closeCantoneseModal(); closeAdminModal();
opener?.focus?.({ preventScroll: true });
if (maintenanceModal) maintenanceModal.hidden = false;
document.body.classList.add('modal-open');
};
document.querySelectorAll('[data-close-maintenance]').forEach(button => {
button.addEventListener('click', closeMaintenanceModal);
});
// Intercept clicks before native required-field validation or credential submission.
document.querySelectorAll('#member-tab-login, #login-form button, #register-form button').forEach(button => {
button.addEventListener('click', event => {
if (!AI_MAINTENANCE) return;
event.preventDefault(); event.stopImmediatePropagation();
openMaintenanceModal(event.currentTarget);
}, true);
});

const authEndpoint = 'https://api.rayalex.cn';
let authState = { user: null, daily: null, cantoneseDaily: null };
const identityScope = new HomeRuntime.Scope();
Expand Down Expand Up @@ -1016,6 +1051,7 @@ <h3>邀请码</h3>
cantonese_daily_limit_exceeded: '今日粤语实时传译 30 秒额度已用完。',
rate_limited: data.retry_after ? `操作太频繁,请 ${data.retry_after} 秒后再试。` : '操作太频繁,请稍后再试。',
channel_paused: 'MiMo 通道已暂停。',
service_maintenance: '维护中,待新模型接入后恢复。',
payload_too_large: '消息或媒体太大,请缩小后再发送。',
unsupported_media_type: '暂时只支持 PNG/JPEG/WebP 图片和 WAV 语音。',
media_too_large: '媒体文件太大,请压缩后再发送。',
Expand Down Expand Up @@ -1212,6 +1248,7 @@ <h3>邀请码</h3>

loginForm?.addEventListener('submit', async (event) => {
event.preventDefault();
if (AI_MAINTENANCE) return openMaintenanceModal();
const button = loginForm.querySelector('button[type="submit"]');
if (button.disabled) return;
const revision = ++authRevision;
Expand Down Expand Up @@ -1260,6 +1297,7 @@ <h3>邀请码</h3>
};

sendRegisterCodeButton?.addEventListener('click', async () => {
if (AI_MAINTENANCE) return openMaintenanceModal();
if (sendRegisterCodeButton.disabled) return;
const inviteCode = document.getElementById('register-invite')?.value.trim();
const email = document.getElementById('register-email')?.value.trim();
Expand Down Expand Up @@ -1294,6 +1332,7 @@ <h3>邀请码</h3>

registerForm?.addEventListener('submit', async (event) => {
event.preventDefault();
if (AI_MAINTENANCE) return openMaintenanceModal();
const password = document.getElementById('register-password')?.value || '';
const confirm = document.getElementById('register-password-confirm')?.value || '';
if (password !== confirm) {
Expand Down Expand Up @@ -1645,6 +1684,7 @@ <h3>邀请码</h3>
}
};
const startVoiceRecording = async () => {
if (AI_MAINTENANCE) return openMaintenanceModal();
if (chatBusy || mediaRecorder || voiceStarting) return;
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) throw new Error('当前浏览器不支持录音。');
voiceScope.stop();
Expand Down Expand Up @@ -1762,6 +1802,7 @@ <h3>邀请码</h3>
return cantoneseEntries[0];
};
const processCantoneseSegment = async (segment) => {
if (AI_MAINTENANCE) throw Object.assign(new Error('维护中'), { retryable: false });
const valid = subtitleScope.token();
if (!segment.audioDataUrl) {
const wavBlob = await convertRecordingToWav(segment.blob, subtitleScope);
Expand Down Expand Up @@ -1861,6 +1902,7 @@ <h3>邀请码</h3>
setCantoneseStatus('已停止。可以复制或清空字幕。', 'info');
};
const startCantoneseTranslator = async () => {
if (AI_MAINTENANCE) return openMaintenanceModal();
if (cantoneseRunning || subtitleStarting) return stopCantoneseTranslator();
subtitleStarting = true;
const valid = subtitleScope.token();
Expand Down Expand Up @@ -1895,7 +1937,8 @@ <h3>邀请码</h3>
setCantoneseStatus(`正在实时传译…每秒刷新同一个字幕块。${formatCantoneseDailySummary(authState.cantoneseDaily)}`, 'success');
startCantoneseSegment();
};
const openCantoneseModal = async () => {
const openCantoneseModal = async (event) => {
if (AI_MAINTENANCE) return openMaintenanceModal(event?.currentTarget);
await refreshAuth();
if (!authState.user) return scrollToMemberAccess('请先登录或注册会员账号,再使用粤语实时传译。');
if (!authState.user.email_verified) return scrollToMemberAccess('请先完成邮箱验证,再使用粤语实时传译。');
Expand Down Expand Up @@ -2016,7 +2059,8 @@ <h3>邀请码</h3>
chatModal.hidden = true;
document.body.classList.remove('modal-open', 'keyboard-open');
};
const openChatModal = async () => {
const openChatModal = async (event) => {
if (AI_MAINTENANCE) return openMaintenanceModal(event?.currentTarget);
await refreshAuth();
if (!authState.user) {
scrollToMemberAccess('请先登录或注册会员账号,再进入 Ray AI Chat。');
Expand Down Expand Up @@ -2084,6 +2128,7 @@ <h3>邀请码</h3>

chatForm?.addEventListener('submit', async (event) => {
event.preventDefault();
if (AI_MAINTENANCE) return openMaintenanceModal();
if (chatBusy) return;
if (!authState.user) return scrollToMemberAccess('请先登录或注册会员账号。');
if (!authState.user.email_verified) return scrollToMemberAccess('请先完成邮箱验证。');
Expand Down Expand Up @@ -2164,6 +2209,7 @@ <h3>邀请码</h3>
} else if (modal === chatModal) closeChatModal();
else if (modal === cantoneseModal) closeCantoneseModal();
else if (modal === adminModal) closeAdminModal();
else if (modal === maintenanceModal) closeMaintenanceModal();
});
document.getElementById('chat-history-toggle')?.addEventListener('click', event => {
const open = chatModal.classList.toggle('history-open');
Expand Down
34 changes: 34 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,40 @@ h3 { font-size: 24px; line-height: 1.25; letter-spacing: -.96px; font-weight: 60
word-break: break-word;
}

.maintenance-modal[hidden] { display: none; }
.maintenance-modal {
position: fixed;
inset: 0;
z-index: 70;
display: grid;
place-items: center;
padding: 20px;
}
.maintenance-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, .6); }
.maintenance-panel {
position: relative;
width: min(420px, 100%);
max-height: calc(100dvh - 40px);
overflow: auto;
padding: 32px 24px 24px;
border-radius: 8px;
background: #fff;
color: var(--ink);
}
.maintenance-panel h2 { margin: 12px 0; font-size: 28px; letter-spacing: 0; }
.maintenance-panel > p:not(.eyebrow) { margin-bottom: 24px; color: var(--muted); line-height: 1.7; }
.maintenance-close {
position: absolute;
top: 8px;
right: 8px;
width: 44px;
height: 44px;
border: 0;
background: transparent;
color: inherit;
font-size: 26px;
cursor: pointer;
}
.cantonese-modal[hidden] { display: none; }
.cantonese-modal {
position: fixed;
Expand Down
26 changes: 24 additions & 2 deletions tests/auth-ui.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const runtime = fs.readFileSync(require.resolve('../home-runtime.js'), 'utf8');
const html = fs.readFileSync(require.resolve('../index.html'), 'utf8');
const script = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].at(-1)[1];
const tick = () => new Promise(resolve => setImmediate(resolve));
function harness() {
function harness({ maintenance = false } = {}) {
const nodes = new Map(), events = new Map(), storage = new Map();
function node(id) {
if (nodes.has(id)) return nodes.get(id);
Expand All @@ -30,9 +30,31 @@ function harness() {
fetch: async (url, options) => { requests.push({ url, options }); if (fail) throw new Error('offline'); return { ok: true, json: async () => ({ ok: true, user, daily: {} }) }; },
};
context.window = context; context.globalThis = context;
vm.createContext(context); vm.runInContext(runtime, context); vm.runInContext(script, context);
// Keep dormant identity/media regressions, and exercise the shipped flag separately below.
const source = maintenance ? script : script.replace('const AI_MAINTENANCE = true;', 'const AI_MAINTENANCE = false;');
vm.createContext(context); vm.runInContext(runtime, context); vm.runInContext(source, context);
return { context, node, events, storage, requests, run: code => vm.runInContext(code, context), setUser: value => { user = value; }, offline: () => { fail = true; } };
}
test('shipped maintenance mode blocks login, registration, AI and microphone work', async () => {
const app = harness({ maintenance: true }); await tick();
app.context.fetch = async () => assert.fail('maintenance action made a network request');
app.context.navigator.mediaDevices.getUserMedia = async () => assert.fail('maintenance requested microphone');
for (const action of ['openChatModal()', 'openCantoneseModal()', 'startVoiceRecording()', 'startCantoneseTranslator()']) {
await app.run(action);
assert.equal(app.node('maintenance-modal').hidden, false);
assert.equal(app.node('chat-modal').hidden, true);
assert.equal(app.node('cantonese-modal').hidden, true);
app.run('closeMaintenanceModal()');
}
for (const id of ['login-form', 'register-form', 'chat-form']) {
await app.node(id).emit('submit');
assert.equal(app.node('maintenance-modal').hidden, false);
app.run('closeMaintenanceModal()');
}
await app.run('sendRegisterCodeButton').emit('click');
assert.equal(app.node('maintenance-modal').hidden, false);
await assert.rejects(app.run('processCantoneseSegment({})'), error => error.retryable === false);
});
test('actual UI restores verification/login/register/logout controls offline and locks local identity', async () => {
const app = harness(); await tick(); app.offline();
app.node('register-invite').value = 'test'; app.node('register-email').value = 'a@test.invalid';
Expand Down
15 changes: 13 additions & 2 deletions tests/game-browser-smoke.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,12 @@ async function workerUpgrade(browser, origin) {
});
legacy = false;
console.log('SW upgrade: requesting safe controller update');
await page.evaluate(async () => {
// Chrome 154 defers an immediate update from the just-registered client by
// about 60 seconds. Use an uncontrolled same-origin client to request the
// real update, while asserting takeover of the original controlled page.
const updatePage = await context.newPage();
await updatePage.goto(origin + '/__blank');
const upgraded = page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
window.__upgradeEvents = [];
registration.addEventListener('updatefound', () => {
Expand All @@ -291,9 +296,15 @@ async function workerUpgrade(browser, origin) {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('controller upgrade timeout')), 30000);
navigator.serviceWorker.addEventListener('controllerchange', () => { clearTimeout(timer); resolve(); }, { once: true });
registration.update().catch(error => { clearTimeout(timer); reject(error); });
window.__upgradeListening = true;
});
});
await page.waitForFunction(() => window.__upgradeListening);
await Promise.all([upgraded, updatePage.evaluate(async url => {
const registration = await navigator.serviceWorker.getRegistration(url);
await registration.update();
}, originalURL)]);
await updatePage.close();
const evidence = await page.evaluate(async () => {
const protocol = await new Promise((resolve, reject) => {
const channel = new MessageChannel();
Expand Down
7 changes: 7 additions & 0 deletions tests/home-browser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ const server = http.createServer((request, response) => {
fs.readFile(file, (error, data) => {
if (error) { response.writeHead(404).end(); return; }
const types = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp' };
// This isolated suite retains dormant auth/media regression coverage. Production
// maintenance behavior is tested separately against the unmodified shipped HTML.
if (file === path.join(root, 'index.html')) {
const source = data.toString();
assert.ok(source.includes('const AI_MAINTENANCE = true;'));
data = Buffer.from(source.replace('const AI_MAINTENANCE = true;', 'const AI_MAINTENANCE = false;'));
}
response.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' }); response.end(data);
});
});
Expand Down
Loading
Loading