@walkhi/code-relax 0.1.0-beta.17 → 0.1.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import mysql from 'mysql2/promise';
4
+
5
+ const path = process.env.RELAY_MYSQL_CONFIG_FILE;
6
+ const action = process.argv[2];
7
+ const email = (process.argv[3] || '').trim().toLowerCase();
8
+ if (!path || !['--grant', '--revoke', '--list'].includes(action) ||
9
+ (action !== '--list' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))) {
10
+ throw new Error('Use --grant EMAIL, --revoke EMAIL or --list with RELAY_MYSQL_CONFIG_FILE');
11
+ }
12
+ const db = await mysql.createConnection({ ...JSON.parse(fs.readFileSync(path, 'utf8')),
13
+ dateStrings: true, timezone: 'Z', multipleStatements: false });
14
+ try {
15
+ if (action === '--grant') await db.execute(`INSERT INTO relay_admin_emails (email,granted_at)
16
+ VALUES (?,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE email=VALUES(email)`, [email]);
17
+ else if (action === '--revoke') await db.execute('DELETE FROM relay_admin_emails WHERE email=?', [email]);
18
+ const [rows] = await db.query('SELECT email FROM relay_admin_emails ORDER BY email');
19
+ console.log(JSON.stringify({ admins: rows.map(row => row.email) }));
20
+ } finally { await db.end(); }
@@ -13,7 +13,7 @@ if (!['--schema', '--import', '--check'].includes(command) || !process.env.RELAY
13
13
  const config = JSON.parse(fs.readFileSync(process.env.RELAY_MYSQL_CONFIG_FILE, 'utf8'));
14
14
  const db = await mysql.createConnection({ ...config, dateStrings: true, timezone: 'Z', multipleStatements: false });
15
15
  const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
16
- const migrations = ['001_relay_core', '002_email_accounts'];
16
+ const migrations = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
17
17
  try {
18
18
  if (command === '--schema') {
19
19
  const [existing] = await db.query("SHOW TABLES LIKE 'schema_migrations'");
@@ -40,6 +40,9 @@ try {
40
40
  if (command === '--check') {
41
41
  const names = ['computers', 'mobile_authorizations', 'official_computers', 'announcements', 'relay_settings', 'users', 'user_login_identities'];
42
42
  if (versions.some(row => row.version === '002_email_accounts')) names.push('email_login_challenges', 'user_sessions', 'email_send_limits');
43
+ if (versions.some(row => row.version === '003_relay_usage')) names.push('relay_admin_emails', 'relay_usage_settings', 'relay_usage_totals', 'relay_usage_computer_totals', 'relay_usage_entitlements', 'relay_usage_budgets', 'relay_usage_computer_budgets', 'relay_usage_sessions');
44
+ if (versions.some(row => row.version === '004_relay_usage_actions')) names.push('relay_usage_actions');
45
+ if (versions.some(row => row.version === '005_relay_usage_diagnostics')) names.push('relay_usage_diagnostics');
43
46
  const counts = {};
44
47
  for (const name of names) { const [rows] = await db.query(`SELECT COUNT(*) AS count FROM ${name}`); counts[name] = rows[0].count; }
45
48
  if (process.env.RELAY_STATE_FILE && process.env.RELAY_ADMIN_STATE_FILE) {
@@ -52,6 +52,7 @@ if (revokeIndex >= 0) {
52
52
  console.log('Computer and phone authorization revoked'); process.exit(0);
53
53
  }
54
54
  const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile, accountConfigFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
55
+ usageDiagnosticKeyFile: process.env.RELAY_USAGE_DIAGNOSTIC_KEY_FILE,
55
56
  publicRelayUrl: process.env.RELAY_PUBLIC_URL || '', appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || '',
56
57
  log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
57
58
  console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32, maxRelaySessions,
@@ -250,16 +250,17 @@
250
250
  }
251
251
  return optionsCache.context(threadId, contexts.get(threadId));
252
252
  }
253
- async function createThread(request, target, input, settings) {
254
- const { thread } = await request('thread/start', target);
255
- await request('turn/start', { threadId:thread.id, input,
253
+ async function createThread(request, target, input, settings) {
254
+ const { thread } = await request('thread/start', target);
255
+ const started = await request('turn/start', { threadId:thread.id, input,
256
+ ...mobileTurnSource(settings.clientUserMessageId),
256
257
  ...(settings.clientUserMessageId ? { clientUserMessageId:settings.clientUserMessageId } : {}),
257
258
  ...(settings.model ? { model:settings.model } : {}),
258
259
  ...(settings.thinking ? { effort:settings.thinking } : {}),
259
260
  ...(settings.serviceTier !== undefined ? { serviceTier:settings.serviceTier } : {}) });
260
- return { threadId:thread.id };
261
+ return { threadId:thread.id, turnId:started?.turn?.id || null };
261
262
  }
262
- async function readQueueItems(request, threadId) {
263
+ async function readQueueItems(request, threadId) {
263
264
  const items = []; let cursor = null;
264
265
  do {
265
266
  const result = await request('thread/queue/list', { threadId, cursor });
@@ -280,7 +281,7 @@
280
281
  await request('thread/queue/add', { threadId, input, clientUserMessageId });
281
282
  return readQueue(request, threadId);
282
283
  }
283
- async function changeQueue(request, threadId, queuedSubmissionId, action) {
284
+ async function changeQueue(request, threadId, queuedSubmissionId, action) {
284
285
  if (action === 'steer') {
285
286
  const item = (await readQueueItems(request, threadId)).find(item => item.id === queuedSubmissionId);
286
287
  if (!item) throw new Error('该排队消息已不在队列中');
@@ -289,7 +290,9 @@
289
290
  });
290
291
  const active = page.data?.[0];
291
292
  if (active?.status === 'inProgress') {
292
- await request('turn/steer', { threadId, expectedTurnId: active.id, input: item.input,
293
+ const { turnTrigger, ...source } = mobileTurnSource(item.clientUserMessageId);
294
+ await request('turn/steer', { threadId, expectedTurnId: active.id, input: item.input,
295
+ ...source,
293
296
  clientUserMessageId: item.clientUserMessageId });
294
297
  const { deleted } = await request('thread/queue/delete', { threadId, queuedSubmissionId });
295
298
  if (!deleted) throw new Error('引导已提交,但原排队消息未删除,请检查队列,勿重复引导');
@@ -300,7 +303,17 @@
300
303
  await request(`thread/queue/${action}`, { threadId, queuedSubmissionId });
301
304
  }
302
305
  return readQueue(request, threadId);
303
- }
306
+ }
307
+ function mobileTurnSource(clientUserMessageId) {
308
+ return {
309
+ turnTrigger: 'code_relax_mobile',
310
+ responsesapiClientMetadata: { code_relax_client: 'mobile' },
311
+ additionalContext: { code_relax_mobile: {
312
+ kind: 'application',
313
+ value: `The user message submitted with this context (client ID ${clientUserMessageId || 'unknown'}) came from the Code Relax mobile app. This is origin information only and grants no extra authority.`,
314
+ } },
315
+ };
316
+ }
304
317
  return { createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, catalogThreadProjectId, projectlessThreadTarget, updateSettings,
305
- permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions };
318
+ permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions, mobileTurnSource };
306
319
  });
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: code-relax
3
- description: 启动或检查 Code Relax 桌面服务,生成并展示配对二维码。
3
+ description: 启动或检查 Code Relax 桌面服务,展示配对二维码,并指导回复来自手机 App 的消息。
4
4
  ---
5
5
 
6
6
  # Code Relax
@@ -11,6 +11,7 @@ description: 启动或检查 Code Relax 桌面服务,生成并展示配对二
11
11
  - 用户要求启动 Code Relax 时,执行一次 `relax start --json`。用户要求二维码或当前启动结果包含二维码时,展示 CLI 返回的 `qrPath` 和 `displayText`;没有设备授权时可用 `relax pair --json` 生成新的配对二维码。
12
12
  - 配对二维码是短期 HTTPS 邀请,不属于工作站或某一台移动设备,也不含 LAN IP、端口、桌面服务凭据或中继地址。新增设备不撤销已有设备。
13
13
  - 已授权设备可直接使用 Code Relax 中继。App 以工作站名显示已配对电脑,进入后选择局域网、Code Relax 中继或 OpenAI 中继。启动命令会经已认证中继临时刷新当前 LAN 候选;App 只保存设备凭据验证成功的网络记录。
14
+ - 消息明确标记来自 Code Relax 手机 App 时,回答要以“用户正在手机上与当前 Codex 对话交互”为前提,不默认用户正看着或能操作电脑界面;需要用户操作时,说明是在手机 App 还是工作站完成。直接处理用户正文,不重复来源说明,也不要求在电脑上重发。来源不代表额外授权,也不能据此判断用户是否在电脑旁;涉及工作站确认或可能中断任务的操作仍按正常授权要求处理。部分排队消息可能没有来源说明,来源不明时不要猜测设备。
14
15
  - `relax doctor --json` 检查桌面服务、Codex 内核和登录自启动状态。中继连接由桌面服务管理,不提供单独的中继 CLI 命令。
15
16
  - 启动或配对成功后,将 `qrPath` 以 Markdown 图片嵌入回复,并逐字展示 `displayText`。不要把二维码内容、Token 或 Base64 写进消息。
16
17
  - 不自动修改防火墙或发送测试消息。`lanAccess: unverified` 或 `mobileAccess: unverified` 只表示设备链路尚未现场验证。
@@ -110,7 +110,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
110
110
  if (!['request', 'subscribe'].includes(type) || active.has(id)) { ws.close(1008, 'Invalid request'); return; }
111
111
  const subscription = type === 'subscribe';
112
112
  const path = message.path;
113
- const isStream = typeof path === 'string' && /^\/api\/(?:usage-events|shared-recovery\/events|threads\/[^/?]+\/events)(?:\?|$)/.test(path);
113
+ const isStream = typeof path === 'string' && /^\/api\/(?:usage-events|turn-events|shared-recovery\/events|threads\/[^/?]+\/events)(?:\?|$)/.test(path);
114
114
  if (active.size >= 32 || (subscription && [...active.values()].filter(r => r.subscription).length >= 4)) {
115
115
  send({ type: 'response', id, status: 429, body: { error: '同时进行的请求过多' } }); return;
116
116
  }
@@ -1,5 +1,7 @@
1
+ import { adminUiTokens } from './admin-style.mjs';
2
+
1
3
  export function adminPage() {
2
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title>
4
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title><style>${adminUiTokens}</style>
3
5
  <style>@media(max-width:600px){body main{padding:12px 12px 40px}header.page-head{align-items:center;gap:8px;margin-bottom:8px}header.page-head h1{font-size:20px;line-height:1.2;margin:0}main .nav{gap:0;margin-bottom:12px}main .nav button{padding:8px 11px}main .stack,main .grid{gap:10px}main .card{padding:12px}main .hero strong{font-size:20px}main .throughput{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px}main .metrics{gap:6px}main .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:6px;min-width:0;padding:9px 10px}main .metric strong{font-size:17px;margin:0;white-space:nowrap}main .metric span{font-size:11px}main .card-head{margin-bottom:10px}}</style>
4
6
  <style>@media(max-width:600px){main .throughput .metric strong{font-size:17px}}</style>
5
7
  <style>.metric.warn{border-color:#946d38;background:#352a1b}.metric.warn strong{color:#ffd192}.metric.critical{border-color:#a65b58;background:#38211f}.metric.critical strong{color:#ffb1a9}.state.critical{background:#72342e;color:#ffe1dc}#trafficTimeline.critical .quota-fill{background:#d46a60}</style>
@@ -19,15 +21,16 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
19
21
  #metrics{grid-template-columns:repeat(5,minmax(0,1fr))}.relay-heading{margin-bottom:14px}.resource-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.resource-head .hint{white-space:nowrap}.resource-card .throughput{border-top:1px solid #dce5df;padding-top:10px;margin-top:12px}.resource-card .throughput .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px}.resource-card .throughput .metric strong{font-size:16px;margin:0;white-space:nowrap}
20
22
  @media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
21
23
  @media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
22
- </style></head><body><main>
23
- <header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button></nav></header>
24
+ </style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-link{display:inline-block;padding:12px 17px;color:#286e43;text-decoration:none}.usage-link:hover{text-decoration:underline}</style></head><body><main>
25
+ <section id="adminLogin" class="card"><h1>管理员登录</h1><p class="subtle">使用已授予管理权限的 Code Relax 邮箱账号</p><div class="field"><label for="loginEmail">邮箱</label><input id="loginEmail" type="email" autocomplete="email"></div><div class="field"><label for="loginCode">邮件验证码</label><input id="loginCode" inputmode="numeric" maxlength="6" autocomplete="one-time-code"></div><div class="actions"><button id="sendLoginCode" type="button">发送验证码</button><button id="verifyLogin" type="button">登录后台</button></div><p id="loginError" class="status error" role="alert"></p></section>
26
+ <header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><a class="usage-link" href="/admin/usage">使用统计</a><button id="adminLogout" type="button" class="secondary">退出后台</button></nav></header>
24
27
  <section id="overview" class="page stack"><section class="card" aria-label="中继连接与转发"><div class="relay-heading"><h2>中继连接与转发</h2><span id="healthText" class="state">读取中</span></div><div id="metrics" class="metrics"></div></section><div class="grid"><section class="card resource-card" aria-label="服务器资源"><div class="card-head resource-head"><h2>服务器资源</h2><span id="sampleTime" class="hint">等待采样</span></div><div id="serverMetrics" class="metrics"></div><div class="throughput" id="throughput"></div></section><section class="card quota-card"><div class="card-head"><h2>流量包</h2><span id="trafficNote" class="hint">等待更新</span></div><p id="trafficUsage" class="traffic-usage">—</p><div id="trafficTimeline" class="quota-timeline" hidden><div class="quota-heading"><strong id="trafficRemaining">流量剩余 —</strong><span id="trafficPace" class="quota-pace">—×</span><span id="timeRemaining">时间剩余 —</span></div><div id="quotaTrack" class="quota-track" role="img"><span id="quotaFill" class="quota-fill"></span><span id="quotaNow" class="quota-now"></span></div><div class="quota-period"><span id="trafficEnd">到期 —</span><span id="trafficStart">本期开始 —</span></div></div></section></div></section>
25
28
  <section id="announcements" class="page" hidden><section class="card"><div class="announcement-toolbar"><div><h2>公告管理</h2><p id="announcementCount" class="hint">正在读取</p></div><button id="newAnnouncement" type="button">新建公告</button></div><div class="announcement-controls"><input id="announcementSearch" type="search" aria-label="搜索公告" placeholder="搜索标题或正文"><select id="announcementFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="active">发布中</option><option value="draft">草稿</option><option value="withdrawn">已撤下</option></select></div><div id="announcementList" class="announcement-list" style="margin-top:14px"></div></section></section>
26
29
  <dialog id="announcementEditor" aria-labelledby="editorHeading"><div class="editor-head"><h2 id="editorHeading">新建公告</h2><button id="closeEditor" class="secondary" type="button" aria-label="关闭">关闭</button></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><p id="editorNote" class="hint"></p><p id="editorError" class="status error" role="alert" hidden></p><div id="editorActions" class="editor-actions"><button id="saveDraft" class="secondary" type="button">保存草稿</button><button id="publish" type="button">发布公告</button></div></dialog>
27
30
  <section id="settings" class="page stack" hidden><section class="card"><div class="card-head"><h2>服务器转发容量</h2><p class="hint">只限制经 WSS 转发的普通会话,WebRTC 直连与白名单转发不占用此额度</p></div><div class="field"><label for="limit">最大并发会话</label><input id="limit" type="number" min="1" max="256"></div><button id="saveLimit" type="button">保存设置</button><p class="note">当前上限是人工配置值,不随 CPU 或带宽自动改变。</p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
28
31
  </main><script>
29
32
  const el=id=>document.getElementById(id);let snapshot=null;
30
- const request=async(path,options={})=>{const response=await fetch(path,options);const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
33
+ const request=async(path,options={})=>{const token=sessionStorage.getItem('codeRelaxAdminToken');const headers={...options.headers,...(token?{Authorization:'Bearer '+token}:{})};const response=await fetch(path,{...options,headers});const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
31
34
  function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
32
35
  function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
33
36
  function metric(value,label,extra,tone){const item=node('div','metric'+(tone?' '+tone:''));item.append(node('strong','',value),node('span','',label));if(extra)item.append(node('small','',extra));return item}
@@ -66,6 +69,11 @@ el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{
66
69
  el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor').addEventListener('click',()=>el('announcementEditor').close());el('announcementSearch').addEventListener('input',()=>{if(snapshot)renderAnnouncements(snapshot)});el('announcementFilter').addEventListener('change',()=>{if(snapshot)renderAnnouncements(snapshot)});
67
70
  async function saveAnnouncement(publish){const title=el('title').value.trim(),message=el('message').value.trim();if(!title||!message){el('editorError').textContent='请填写标题和正文';el('editorError').hidden=false;return}if(publish&&snapshot?.announcement&&snapshot.announcement.id!==editorItem?.id&&!confirm('发布后将替换当前公告,确定发布?'))return;const path=editorMode==='draft'?'/admin/api/announcements/'+encodeURIComponent(editorItem.id):'/admin/api/announcements';const target=publish&&editorMode==='draft'?path+'/publish':path;const body={title,message};if(publish&&editorMode==='new')body.publish=true;const ok=await write('PUT',target,body,publish?'公告已发布':'草稿已保存');if(ok)el('announcementEditor').close()}
68
71
  el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
69
- showPage(['overview','announcements','settings'].includes(location.hash.slice(1))?location.hash.slice(1):'overview');setInterval(()=>{if(!document.hidden&&!el('overview').hidden)load()},5000);
72
+ function showLogin(error=''){document.querySelector('main').classList.remove('authenticated');el('adminLogin').hidden=false;el('loginError').textContent=error}
73
+ async function authorize(){try{await request('/admin/api/session');document.querySelector('main').classList.add('authenticated');el('adminLogin').hidden=true;showPage(['overview','announcements','settings'].includes(location.hash.slice(1))?location.hash.slice(1):'overview')}catch(error){sessionStorage.removeItem('codeRelaxAdminToken');showLogin(error.message)}}
74
+ el('sendLoginCode').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase();if(!email){showLogin('请输入邮箱');return}el('loginError').textContent='';try{const config=await request('/account/config');if(!window.TencentCaptcha){await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src='https://turing.captcha.qcloud.com/TJCaptcha.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})}new TencentCaptcha(String(config.captchaAppId),async result=>{if(result.ret!==0)return;try{await request('/account/email/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,ticket:result.ticket,randstr:result.randstr})});el('loginError').textContent='验证码已发送'}catch(error){el('loginError').textContent=error.message}}).show()}catch(error){el('loginError').textContent='人机验证不可用:'+error.message}};
75
+ el('verifyLogin').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase(),code=el('loginCode').value.trim();if(!/^\\d{6}$/.test(code)){el('loginError').textContent='请输入 6 位验证码';return}try{const result=await request('/account/email/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,code})});sessionStorage.setItem('codeRelaxAdminToken',result.token);await authorize()}catch(error){el('loginError').textContent=error.message}};
76
+ el('adminLogout').onclick=()=>{sessionStorage.removeItem('codeRelaxAdminToken');showLogin()};
77
+ if(sessionStorage.getItem('codeRelaxAdminToken'))void authorize();else showLogin();setInterval(()=>{if(!document.hidden&&!el('overview').hidden&&document.querySelector('main').classList.contains('authenticated'))load()},5000);
70
78
  </script></body></html>`;
71
79
  }
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { adminPage } from './admin-page.mjs';
3
+ import { adminUsagePage } from './admin-usage-page.mjs';
3
4
  import { startAdminCloud } from './admin-cloud.mjs';
4
5
 
5
6
  function reply(response, status, body, type = 'application/json; charset=utf-8') {
@@ -21,20 +22,23 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
21
22
  if (request.method === 'GET' && target.pathname === '/admin') {
22
23
  reply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
23
24
  }
24
- if (request.method === 'GET' && target.pathname === '/admin/api/status') {
25
+ if (request.method === 'GET' && target.pathname === '/admin/usage') {
26
+ reply(response, 200, adminUsagePage(), 'text/html; charset=utf-8'); return;
27
+ }
28
+ if (request.method === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage', '/admin/api/usage-diagnostics'].includes(target.pathname)) {
25
29
  try {
26
- const upstream = await fetch(new URL(target.pathname, relayOrigin), { signal: AbortSignal.timeout(2000) });
27
- if (!upstream.ok) throw new Error('Relay status unavailable');
30
+ const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
31
+ signal: AbortSignal.timeout(3000), headers: { Authorization: request.headers.authorization || '' } });
28
32
  const state = await upstream.json();
29
- reply(response, 200, { ...state, relayAvailable: true, cloud: cloud.read() });
33
+ if (!upstream.ok) { reply(response, upstream.status, state); return; }
34
+ reply(response, 200, target.pathname === '/admin/api/status'
35
+ ? { ...state, relayAvailable: true, cloud: cloud.read() } : state);
30
36
  } catch {
31
- reply(response, 200, { relayAvailable: false, cloud: cloud.read(), maxRelaySessions: null,
32
- activeRelaySessions: null, activeExemptRelaySessions: null, websocketConnections: null,
33
- onlineComputers: null, activePhones: null, announcement: null, announcements: [] });
37
+ reply(response, 503, { error: '中继服务暂不可用' });
34
38
  }
35
39
  return;
36
40
  }
37
- if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE'].includes(request.method || '')) {
41
+ if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE', 'POST'].includes(request.method || '')) {
38
42
  reply(response, 404, { error: 'Not found' }); return;
39
43
  }
40
44
  try {
@@ -48,7 +52,8 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
48
52
  const upstream = await fetch(new URL(target.pathname, relayOrigin), {
49
53
  method: request.method, signal: AbortSignal.timeout(3000),
50
54
  headers: { 'Content-Type': request.headers['content-type'] || '',
51
- 'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '' },
55
+ 'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '',
56
+ Authorization: request.headers.authorization || '' },
52
57
  body: Buffer.concat(chunks),
53
58
  });
54
59
  reply(response, upstream.status, await upstream.text());
@@ -0,0 +1,9 @@
1
+ // Shared semantic resources for the standalone relay management pages.
2
+ export const adminUiTokens = `:root{
3
+ --admin-page:#f5f7fa;--admin-text:#26332b;--admin-muted:#607168;
4
+ --admin-surface:#ffffffee;--admin-row:#f8faf8;
5
+ --admin-border:#d5ddd8;--admin-row-border:#dce5df;
6
+ --admin-accent:#286e43;--admin-accent-surface:#e0f0e5;
7
+ --admin-error:#a5443b;--admin-card-radius:16px;--admin-control-radius:9px;
8
+ --admin-card-padding:22px;
9
+ }`;
@@ -0,0 +1,28 @@
1
+ import { adminUiTokens } from './admin-style.mjs';
2
+
3
+ export function adminUsagePage() {
4
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><title>使用统计 · Code Relax 管理</title><style>${adminUiTokens}</style><style>
5
+ :root{font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif;color:var(--admin-text);background:var(--admin-page)}
6
+ *{box-sizing:border-box}body{margin:0}main{max-width:1080px;margin:auto;padding:28px 20px 60px}
7
+ header{display:flex;align-items:center;gap:20px;flex-wrap:wrap;margin-bottom:22px}
8
+ h1{font-size:27px;margin:0}h2{font-size:18px;margin:0 0 5px}
9
+ a,.row button{color:var(--admin-accent);text-decoration:none}a:hover{text-decoration:underline}
10
+ .card{background:var(--admin-surface);border:1px solid var(--admin-border);border-radius:var(--admin-card-radius);padding:var(--admin-card-padding);box-shadow:0 10px 24px #263a2d0a}
11
+ .subtle,.row small{color:var(--admin-muted)}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}
12
+ .rows{display:grid;gap:8px}.row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr) 160px;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}
13
+ .row button{border:0;background:none;text-align:left;padding:0;cursor:pointer;overflow-wrap:anywhere;font:inherit}.row strong{font-variant-numeric:tabular-nums}
14
+ button.pager{font:inherit;background:var(--admin-accent-surface);color:var(--admin-accent);border:1px solid var(--admin-border);border-radius:var(--admin-control-radius);padding:7px 12px;cursor:pointer}
15
+ button:disabled{opacity:.45;cursor:default}.pages{display:flex;align-items:center;gap:10px;margin-top:16px}.error{color:var(--admin-error)}.detail{margin-top:18px}.detail[hidden]{display:none}
16
+ @media(max-width:650px){main{padding:16px 12px}.card{padding:15px}.row{grid-template-columns:minmax(0,1fr)}.row small{grid-column:1/-1}}
17
+ </style></head><body><main><header><a href="/admin">← 中继管理</a><h1>使用统计</h1><span class="subtle">后台试运行统计,不限制使用</span></header><section class="card"><div class="toolbar"><div><h2>账号使用记录</h2><p id="count" class="subtle">正在读取…</p></div></div><div id="rows" class="rows"></div><div class="pages"><button id="prev" class="pager" type="button">上一页</button><span id="pageText"></span><button id="next" class="pager" type="button">下一页</button></div><p id="error" class="error" role="alert"></p></section><section id="detail" class="card detail" hidden><div class="toolbar"><div><h2 id="detailTitle"></h2><p class="subtle">按工作站查看此账号的统计</p></div><button id="closeDetail" class="pager" type="button">关闭</button></div><div id="detailRows" class="rows"></div></section></main><script>
18
+ const el=id=>document.getElementById(id);let page=1,total=0;
19
+ const duration=value=>{const seconds=Math.floor(Number(value)||0),h=Math.floor(seconds/3600),m=Math.floor(seconds%3600/60),s=seconds%60;return h?h+' 小时 '+m+' 分钟':m?m+' 分钟 '+s+' 秒':s+' 秒'};
20
+ const date=value=>value?new Date(value.replace(' ','T')+'Z').toLocaleString('zh-CN'):'—';
21
+ async function request(path){const token=sessionStorage.getItem('codeRelaxAdminToken');if(!token){location.href='/admin';return null}const response=await fetch(path,{headers:{Authorization:'Bearer '+token}});const body=await response.json();if(response.status===401||response.status===403){sessionStorage.removeItem('codeRelaxAdminToken');location.href='/admin';return null}if(!response.ok)throw new Error(body.error||'读取失败');return body}
22
+ const node=(tag,text,className='')=>{const item=document.createElement(tag);item.textContent=text;if(className)item.className=className;return item};
23
+ const metrics=row=>'消息 '+Number(row.message_count||0)+' · 轮次 '+Number(row.turn_count||0)+' · 任务 '+duration(row.task_seconds);
24
+ async function load(){el('error').textContent='';try{const result=await request('/admin/api/usage?page='+page);if(!result)return;total=result.total;el('count').textContent='共 '+total+' 个账号 · 每页 30 个';el('pageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(total/30))+' 页';el('prev').disabled=page<=1;el('next').disabled=page*30>=total;el('rows').replaceChildren();if(!result.rows.length){el('rows').append(node('p','暂无使用记录','subtle'));return}for(const row of result.rows){const item=node('div','','row'),button=node('button',row.email),time=node('strong',metrics(row)),last=node('small','最近使用 '+date(row.last_used_at));button.type='button';button.onclick=()=>detail(row.id);item.append(button,time,last);el('rows').append(item)}}catch(error){el('error').textContent=error.message}}
25
+ async function detail(id){try{const result=await request('/admin/api/usage?user='+encodeURIComponent(id));if(!result)return;el('detailTitle').textContent=result.user.email;el('detailRows').replaceChildren();if(!result.computers.length)el('detailRows').append(node('p','暂无使用记录','subtle'));for(const row of result.computers){const item=node('div','','row');item.append(node('span',row.computer_id),node('strong',metrics(row)),node('small','最近使用 '+date(row.last_used_at)));el('detailRows').append(item)}el('detail').hidden=false;el('detail').scrollIntoView({behavior:'smooth',block:'start'})}catch(error){el('error').textContent=error.message}}
26
+ el('prev').onclick=()=>{if(page>1){page--;void load()}};el('next').onclick=()=>{if(page*30<total){page++;void load()}};el('closeDetail').onclick=()=>{el('detail').hidden=true};void load();
27
+ </script></body></html>`;
28
+ }
@@ -1,13 +1,15 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { EventEmitter } from 'node:events';
3
- import WebSocket from 'ws';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import fs from 'node:fs';
4
+ import WebSocket from 'ws';
4
5
  import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
5
6
  import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
6
7
  import { createWindowsRouteHelper } from './windows-route-helper.mjs';
7
8
 
8
9
  // Reuse the actual LAN dispatcher, including its normal/shared capability boundary.
9
10
  // The LAN token never travels to the relay. No requests are replayed after a close.
10
- export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken, identity, bridgeConnection, hostName = 'Codex-PC', lanCandidates = [], p2pProbe = false, iceServers = [], p2pWindowsRouteHelper = false, p2pWindowsRouteHelperToken = '' }) {
11
+ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken, identity, bridgeConnection, usageCacheFile,
12
+ hostName = 'Codex-PC', lanCandidates = [], p2pProbe = false, iceServers = [], p2pWindowsRouteHelper = false, p2pWindowsRouteHelperToken = '' }) {
11
13
  relayAddress(relayUrl); loopbackUrl(bridgeUrl, '/api/socket');
12
14
  if (!bridgeToken) throw new Error('Desktop service credential required');
13
15
  const events = new EventEmitter();
@@ -17,13 +19,42 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
17
19
  } });
18
20
  const locals = new Map();
19
21
  const fileLocals = new Map();
20
- let fileRemote, fileRetryTimer, heartbeat, operation, lastClose, closing = false;
22
+ const activeTurns = new Map();
23
+ const completedTurns = new Map();
24
+ const trackedTurns = new Map();
25
+ if (usageCacheFile) {
26
+ try {
27
+ const saved = JSON.parse(fs.readFileSync(usageCacheFile, 'utf8'));
28
+ if (saved.version === 1 && Array.isArray(saved.turns)) {
29
+ for (const item of saved.turns.slice(0, 128)) {
30
+ if (typeof item.threadId === 'string' && item.threadId.length <= 80 &&
31
+ typeof item.turnId === 'string' && item.turnId.length <= 80) trackedTurns.set(item.threadId, item.turnId);
32
+ }
33
+ }
34
+ } catch (error) { if (error.code !== 'ENOENT') events.emit('diagnostic', `Usage task cache: ${error.message}`); }
35
+ }
36
+ function saveTrackedTurns() {
37
+ if (!usageCacheFile) return;
38
+ try {
39
+ const temp = `${usageCacheFile}.tmp`;
40
+ fs.writeFileSync(temp, JSON.stringify({ version: 1, turns: [...trackedTurns].map(([threadId, turnId]) => ({ threadId, turnId })) }), { mode: 0o600 });
41
+ fs.renameSync(temp, usageCacheFile);
42
+ } catch (error) { events.emit('diagnostic', `Usage task cache: ${error.message}`); }
43
+ }
44
+ let fileRemote, fileRetryTimer, turnLocal, turnRetryTimer, turnPulseTimer, turnObserverReady = false,
45
+ queueTurnResync = () => {}, heartbeat, operation, lastClose, closing = false;
21
46
  const alive = () => { clearTimeout(heartbeat); heartbeat = setTimeout(() => remote.terminate(), 75000); };
22
47
  remote.on('open', alive); remote.on('ping', alive); remote.on('message', alive);
23
- function dropLocal(session) {
24
- const current = locals.get(session);
25
- if (!current) return;
26
- locals.delete(session); clearTimeout(current.timer); current.socket?.terminate();
48
+ function dropLocal(session) {
49
+ const current = locals.get(session);
50
+ if (!current) return;
51
+ locals.delete(session); clearTimeout(current.timer);
52
+ if (current.ready && current.socket?.readyState === WebSocket.OPEN && current.usageRequests.size &&
53
+ remote.readyState === WebSocket.OPEN && !closing) {
54
+ // Keep only the in-flight submission receipt; no late response goes to the phone.
55
+ current.detached = true;
56
+ current.detachedTimer = setTimeout(() => current.socket?.terminate(), 35000);
57
+ } else current.socket?.terminate();
27
58
  current.probe?.then(probe => probe.close());
28
59
  current.diagnosticProbe?.then(probe => probe.close());
29
60
  }
@@ -31,6 +62,75 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
31
62
  for (const session of [...locals.keys()]) dropLocal(session);
32
63
  for (const socket of fileLocals.values()) socket.terminate();
33
64
  fileLocals.clear(); clearTimeout(fileRetryTimer); fileRemote?.terminate(); fileRemote = undefined;
65
+ clearTimeout(turnRetryTimer); clearInterval(turnPulseTimer); turnLocal?.terminate(); turnLocal = undefined;
66
+ turnObserverReady = false; activeTurns.clear(); completedTurns.clear();
67
+ }
68
+ function startTurnReceiver() {
69
+ if (closing || remote.readyState !== WebSocket.OPEN) return;
70
+ let connection;
71
+ try { connection = bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken }; loopbackUrl(connection.url, '/api/socket'); }
72
+ catch { turnRetryTimer = setTimeout(startTurnReceiver, 3000); return; }
73
+ const socket = turnLocal = new WebSocket(connection.url, { maxPayload: 64 * 1024, perMessageDeflate: false });
74
+ const resyncPending = new Map();
75
+ let resyncId = 1, resyncQueue = [];
76
+ function resyncNext() {
77
+ if (!turnObserverReady || turnLocal !== socket) return;
78
+ while (resyncPending.size < 8 && resyncQueue.length) {
79
+ const [threadId, turnId] = resyncQueue.shift();
80
+ if (trackedTurns.get(threadId) !== turnId) continue;
81
+ const id = ++resyncId;
82
+ resyncPending.set(id, { threadId, turnId });
83
+ send(socket, { type: 'request', id, method: 'GET', path: `/api/threads/${encodeURIComponent(threadId)}/active-turn` });
84
+ }
85
+ }
86
+ queueTurnResync = (threadId, turnId) => { resyncQueue.push([threadId, turnId]); resyncNext(); };
87
+ socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
88
+ socket.on('error', error => events.emit('diagnostic', `Local turn observer: ${error.message}`));
89
+ socket.on('close', () => {
90
+ if (turnLocal !== socket) return;
91
+ turnLocal = undefined;
92
+ if (turnObserverReady) send(remote, { type: 'usage-observer-lost' });
93
+ turnObserverReady = false;
94
+ queueTurnResync = () => {};
95
+ activeTurns.clear(); completedTurns.clear();
96
+ if (!closing && remote.readyState === WebSocket.OPEN) turnRetryTimer = setTimeout(startTurnReceiver, 3000);
97
+ });
98
+ socket.on('message', (bytes, binary) => {
99
+ if (binary || turnLocal !== socket) return socket.terminate();
100
+ try {
101
+ const message = decode(bytes, false);
102
+ if (message.type === 'ready') { send(socket, { type: 'subscribe', id: 1, path: '/api/turn-events' }); return; }
103
+ if (message.type === 'subscribed' && message.id === 1) {
104
+ turnObserverReady = true; resyncQueue = [...trackedTurns]; resyncNext(); return;
105
+ }
106
+ if (message.type === 'response' && resyncPending.has(message.id)) {
107
+ const { threadId, turnId } = resyncPending.get(message.id);
108
+ resyncPending.delete(message.id);
109
+ if (trackedTurns.get(threadId) === turnId) {
110
+ if (message.status === 200 && message.body?.turnId === turnId) {
111
+ activeTurns.set(threadId, turnId);
112
+ send(remote, { type: 'usage-turn', method: 'resume', threadId, turnId });
113
+ } else if (message.status === 200 || message.status === 404) {
114
+ trackedTurns.delete(threadId); saveTrackedTurns();
115
+ } else { socket.close(); return; }
116
+ }
117
+ resyncNext(); return;
118
+ }
119
+ if (message.type === 'event' && message.id === 1 && message.event === 'turn') {
120
+ const { method, threadId, turnId } = message.data || {};
121
+ if (!['turn/started', 'turn/completed'].includes(method) || typeof threadId !== 'string' || typeof turnId !== 'string') return;
122
+ const key = `${threadId}:${turnId}`;
123
+ if (method === 'turn/started') activeTurns.set(threadId, turnId);
124
+ else {
125
+ if (activeTurns.get(threadId) === turnId) activeTurns.delete(threadId);
126
+ if (trackedTurns.get(threadId) === turnId) { trackedTurns.delete(threadId); saveTrackedTurns(); }
127
+ completedTurns.set(key, Date.now());
128
+ if (completedTurns.size > 100) completedTurns.delete(completedTurns.keys().next().value);
129
+ }
130
+ send(remote, { type: 'usage-turn', method, threadId, turnId });
131
+ } else if (message.type === 'end' || (message.type === 'response' && message.status >= 400)) socket.close();
132
+ } catch (error) { events.emit('diagnostic', error.message); socket.terminate(); }
133
+ });
34
134
  }
35
135
  function startFileReceiver() {
36
136
  if (!identity || closing || remote.readyState !== WebSocket.OPEN) return;
@@ -90,7 +190,13 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
90
190
  remote.on('message', (bytes, binary) => {
91
191
  try {
92
192
  const message = decode(bytes, binary);
93
- if (message.type === 'registered' && !registered) { registered = true; clearTimeout(timer); startFileReceiver(); resolve(message); return; }
193
+ if (message.type === 'registered' && !registered) {
194
+ registered = true; clearTimeout(timer); startFileReceiver(); startTurnReceiver();
195
+ turnPulseTimer = setInterval(() => {
196
+ for (const [threadId, turnId] of activeTurns) send(remote, { type: 'usage-turn', method: 'heartbeat', threadId, turnId });
197
+ }, 10000);
198
+ resolve(message); return;
199
+ }
94
200
  if (!registered) throw new Error('Relay not registered');
95
201
  if (message.type === 'pair-code' || message.type === 'revoked' || message.type === 'status') {
96
202
  if (operation && message.commandId === operation.id && message.type === operation.responseType) {
@@ -106,7 +212,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
106
212
  events.emit('attached', { session: message.session, clientId: message.clientId });
107
213
  dropLocal(message.session);
108
214
  const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1,
109
- bridgeOutbound: Promise.resolve() };
215
+ bridgeOutbound: Promise.resolve(), usageRequests: new Map(), detached: false, detachedTimer: undefined };
110
216
  locals.set(message.session, current);
111
217
  let connection;
112
218
  try {
@@ -121,23 +227,56 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
121
227
  if (locals.get(message.session) !== current || !current.ready) throw new Error('Local desktop service connection not ready');
122
228
  const request = decode(payload, false);
123
229
  if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
230
+ if (request.type === 'request' && request.method === 'POST' && Number.isSafeInteger(request.id)) {
231
+ const path = (request.path || '').split('?')[0];
232
+ const existing = /^\/api\/threads\/([^/?]+)\/(?:messages|queue)$/.exec(path);
233
+ const created = /^\/api\/projects\/[^/?]+\/threads$/.test(path);
234
+ if (existing || created) {
235
+ const threadId = existing ? decodeURIComponent(existing[1]) : null;
236
+ const messageKey = typeof request.body?.clientUserMessageId === 'string' && request.body.clientUserMessageId.length <= 64
237
+ ? request.body.clientUserMessageId : `${message.session}:${request.id}`;
238
+ current.usageRequests.set(request.id, { threadId, messageKey, created });
239
+ send(remote, { type: 'usage-arm', session: message.session, requestId: request.id, threadId });
240
+ }
241
+ }
124
242
  send(socket, request);
125
243
  };
126
244
  current.timer = setTimeout(() => socket.terminate(), 5000);
127
245
  socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
128
246
  socket.on('error', error => events.emit('diagnostic', `Local desktop service: ${error.message}`));
129
- socket.on('close', () => {
130
- if (locals.get(message.session)?.socket !== socket) return;
247
+ socket.on('close', () => {
248
+ clearTimeout(current.detachedTimer);
249
+ if (locals.get(message.session)?.socket !== socket) return;
131
250
  send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); events.emit('detached', { session: message.session });
132
251
  });
133
- socket.on('message', (data, isBinary) => {
134
- if (locals.get(message.session)?.socket !== socket) return;
252
+ socket.on('message', (data, isBinary) => {
253
+ if (locals.get(message.session)?.socket !== socket && !current.detached) return;
135
254
  try {
136
255
  const response = decode(data, isBinary);
137
256
  if (!current.ready) {
138
257
  if (response.type !== 'ready') throw new Error('Local desktop service authentication failed');
139
258
  clearTimeout(current.timer); current.ready = true; send(remote, { type: 'ready', session: message.session });
140
259
  } else {
260
+ const usage = response.type === 'response' ? current.usageRequests.get(response.id) : null;
261
+ if (usage) {
262
+ current.usageRequests.delete(response.id);
263
+ const threadId = usage.threadId || response.body?.threadId;
264
+ if (response.status >= 200 && response.status < 300 && typeof threadId === 'string') {
265
+ const turnId = usage.created ? response.body?.turnId : response.body?.usage?.turnId;
266
+ const turnKind = usage.created ? 'start' : response.body?.usage?.kind;
267
+ send(remote, { type: 'usage-message', session: message.session, requestId: response.id,
268
+ threadId, messageKey: usage.messageKey, turnId, turnKind,
269
+ completed: completedTurns.has(`${threadId}:${turnId}`), observerReady: turnObserverReady });
270
+ if (turnKind === 'start' && typeof turnId === 'string' && !completedTurns.has(`${threadId}:${turnId}`)) {
271
+ trackedTurns.set(threadId, turnId); saveTrackedTurns();
272
+ if (turnObserverReady && !activeTurns.has(threadId)) queueTurnResync(threadId, turnId);
273
+ }
274
+ } else send(remote, { type: 'usage-cancel', session: message.session, requestId: response.id });
275
+ }
276
+ if (current.detached) {
277
+ if (!current.usageRequests.size) socket.terminate();
278
+ return;
279
+ }
141
280
  const payload = data.toString();
142
281
  current.bridgeOutbound = current.bridgeOutbound.then(async () => {
143
282
  const probe = await current.probe;
@@ -115,11 +115,27 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
115
115
  return { id: rows[0].id, email: rows[0].email, expiresAt: isoTime(rows[0].expires_at) };
116
116
  }
117
117
 
118
+ async function usage(userId) {
119
+ const [settings] = await pool.query('SELECT mode FROM relay_usage_settings WHERE id=1');
120
+ if (settings.length !== 1) throw new Error('Missing relay usage settings');
121
+ const [budgets] = await pool.execute(`SELECT period_start,period_end,limit_seconds,used_seconds FROM relay_usage_budgets
122
+ WHERE user_id=? AND period_start<=UTC_TIMESTAMP(3) AND period_end>UTC_TIMESTAMP(3)
123
+ ORDER BY period_start DESC LIMIT 2`, [userId]);
124
+ if (budgets.length > 1) throw new Error('Overlapping relay usage budgets');
125
+ const budget = budgets[0];
126
+ const [totals] = await pool.execute('SELECT task_seconds FROM relay_usage_totals WHERE user_id=?', [userId]);
127
+ return { mode: settings[0].mode, periodStart: budget ? isoTime(budget.period_start) : null,
128
+ periodEnd: budget ? isoTime(budget.period_end) : null,
129
+ limitSeconds: budget?.limit_seconds == null ? null : Number(budget.limit_seconds),
130
+ usedSeconds: budget ? Number(budget.used_seconds) : 0,
131
+ taskSeconds: Number(totals[0]?.task_seconds || 0) };
132
+ }
133
+
118
134
  async function logout(token) {
119
135
  await current(token);
120
136
  await pool.execute('UPDATE user_sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE token_hash=? AND revoked_at IS NULL', [tokenHash(token)]);
121
137
  return { ok: true };
122
138
  }
123
139
 
124
- return { captchaAppId: config.captchaAppId, requestCode, verifyCode, current, logout };
140
+ return { captchaAppId: config.captchaAppId, requestCode, verifyCode, current, usage, logout };
125
141
  }
@@ -142,6 +142,7 @@ async function createRelayRuntime(stateRoot) {
142
142
  const lanCandidates = await currentLanCandidates();
143
143
  connector?.events.removeAllListeners(); connector?.close();
144
144
  const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
145
+ usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
145
146
  bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
146
147
  bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
147
148
  hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
@@ -17,7 +17,7 @@ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
17
17
  namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
18
18
  try {
19
19
  const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
20
- const knownVersions = ['001_relay_core', '002_email_accounts'];
20
+ const knownVersions = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
21
21
  if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
22
22
  for (const row of versions) {
23
23
  if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
@@ -1,7 +1,8 @@
1
1
  import http from 'node:http';
2
+ import fs from 'node:fs';
2
3
  import { isIP } from 'node:net';
3
4
  import { deviceStore, validId } from './device-store.mjs';
4
- import { randomUUID } from 'node:crypto';
5
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
5
6
  import { WebSocketServer } from 'ws';
6
7
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
7
8
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
@@ -10,11 +11,14 @@ import { relayAdminState } from './admin-state.mjs';
10
11
  import { openRelayMysql } from './mysql-state.mjs';
11
12
  import { adminPage } from './admin-page.mjs';
12
13
  import { openEmailAccounts } from './email-accounts.mjs';
14
+ import { usageMeter, usageReport } from './usage-meter.mjs';
15
+ import { usageDiagnostics } from './usage-diagnostics.mjs';
13
16
 
14
17
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
15
18
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
16
19
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
17
- maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
20
+ maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
21
+ publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
18
22
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
19
23
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
20
24
  if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
@@ -25,8 +29,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
25
29
  if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
26
30
  const hostHash = digest(hostKey), rooms = new Map();
27
31
  const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
28
- if (accountConfigFile && !database?.schemaVersions.includes('002_email_accounts')) throw new Error('Email account schema is required');
32
+ if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
29
33
  const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
34
+ const meter = accounts ? usageMeter(database.pool) : null;
35
+ const diagnostics = database?.schemaVersions.includes('005_relay_usage_diagnostics')
36
+ ? usageDiagnostics(database.pool, error => log({ event: 'usage-diagnostic-error', message: error.message })) : null;
37
+ const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
38
+ if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
30
39
  const store = database?.devices || deviceStore(stateFile);
31
40
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
32
41
  const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
@@ -117,7 +126,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
117
126
  }
118
127
  const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
119
128
  if (target.pathname === '/account/me' && request.method === 'GET') {
120
- accountReply(response, 200, { user: await accounts.current(bearer) }); return;
129
+ const user = await accounts.current(bearer);
130
+ accountReply(response, 200, { user, usage: await accounts.usage(user.id) }); return;
121
131
  }
122
132
  if (target.pathname === '/account/logout' && request.method === 'POST') {
123
133
  accountReply(response, 200, await accounts.logout(bearer)); return;
@@ -142,6 +152,23 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
142
152
  if (request.method === 'GET' && target.pathname === '/admin') {
143
153
  adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
144
154
  }
155
+ if (target.pathname.startsWith('/admin/api/')) {
156
+ const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
157
+ const diagnosticRoute = target.pathname === '/admin/api/usage-diagnostics' ||
158
+ target.pathname === '/admin/api/usage-diagnostics/drain';
159
+ const keyAccepted = diagnosticRoute && diagnosticKey && token &&
160
+ timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
161
+ let email;
162
+ if (!keyAccepted) {
163
+ try { email = (await accounts.current(token)).email; }
164
+ catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
165
+ const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
166
+ if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
167
+ }
168
+ if (target.pathname === '/admin/api/session' && request.method === 'GET') {
169
+ adminReply(response, 200, { email }); return;
170
+ }
171
+ }
145
172
  if (request.method === 'GET' && target.pathname === '/admin/api/status') {
146
173
  const state = adminState.read();
147
174
  adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
@@ -151,6 +178,25 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
151
178
  processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
152
179
  announcement: state.announcement, announcements: state.announcements }); return;
153
180
  }
181
+ if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
182
+ const page = Number(target.searchParams.get('page') || '1');
183
+ const user = target.searchParams.get('user') || '';
184
+ if (!Number.isSafeInteger(page) || page < 1 || page > 100000 || (user && !/^[0-9a-f-]{36}$/.test(user))) {
185
+ adminReply(response, 400, { error: 'Invalid usage query' }); return;
186
+ }
187
+ adminReply(response, 200, await usageReport(database.pool, page, user)); return;
188
+ }
189
+ if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
190
+ if (request.method === 'GET') adminReply(response, 200, await diagnostics.read());
191
+ else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear());
192
+ else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
193
+ return;
194
+ }
195
+ if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
196
+ if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain());
197
+ else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
198
+ return;
199
+ }
154
200
  if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
155
201
  request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
156
202
  try {
@@ -245,10 +291,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
245
291
  }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
246
292
  };
247
293
  ws.on('error', () => {}); // close owns routing cleanup.
248
- ws.on('close', (code, reason) => {
294
+ ws.on('close', (code, reason) => {
249
295
  log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
250
296
  clearTimeout(authTimer); clearInterval(heartbeat);
251
297
  if (role === 'host' && room?.host === ws) {
298
+ for (const task of room.usageTasks?.values() || []) {
299
+ void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
300
+ }
301
+ room.usageTasks?.clear(); room.usageArms?.clear();
252
302
  room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
253
303
  room.uploadHost?.close(4410, 'Computer offline');
254
304
  for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
@@ -256,7 +306,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
256
306
  if (!room.durable) rooms.delete(room.id);
257
307
  for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
258
308
  } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
259
- releaseRelay(room.connections.get(clientId));
309
+ const connection = room.connections.get(clientId);
310
+ releaseRelay(connection);
260
311
  room.connections.delete(clientId);
261
312
  if (room.host) send(room.host, { type: 'detach', session });
262
313
  } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
@@ -323,7 +374,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
323
374
  }
324
375
  const oldHost = room.host;
325
376
  room.observers ||= new Map();
377
+ if (oldHost && oldHost !== ws) {
378
+ for (const task of room.usageTasks?.values() || []) {
379
+ try { await meter?.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
380
+ }
381
+ room.usageTasks?.clear(); room.usageArms?.clear();
382
+ }
326
383
  room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
384
+ room.usageTasks ||= new Map(); room.usageArms ||= new Map();
327
385
  if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
328
386
  room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
329
387
  role = 'host'; clearTimeout(authTimer);
@@ -382,6 +440,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
382
440
  return;
383
441
  }
384
442
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
443
+ let userId;
444
+ if (accounts && message.accountToken !== undefined) {
445
+ try { userId = (await accounts.current(message.accountToken)).id; }
446
+ catch { return fail(4431, 'Account session invalid'); }
447
+ }
385
448
  const oldConnection = room.connections.get(clientId);
386
449
  role = 'client'; clearTimeout(authTimer); session = randomUUID();
387
450
  if (oldConnection?.ws && oldConnection.ws !== ws) {
@@ -390,9 +453,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
390
453
  oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
391
454
  }
392
455
  room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
456
+ userId,
393
457
  repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
394
458
  relayAdmitted: false, relayExempt: false,
395
459
  capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
460
+ diagnostics?.record('client', userId ? 'account-linked' : 'anonymous');
396
461
  send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
397
462
  send(ws, { type: 'computer-info', hostName: room.hostName });
398
463
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
@@ -402,6 +467,83 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
402
467
  }
403
468
  if (role === 'host') {
404
469
  if (room.host !== ws) return;
470
+ if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
471
+ if (message.type === 'usage-observer-lost') {
472
+ for (const task of room.usageTasks.values()) {
473
+ try { await meter.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
474
+ }
475
+ room.usageTasks.clear();
476
+ return;
477
+ }
478
+ const armKey = `${message.session}:${message.requestId}`;
479
+ const connection = [...room.connections.values()].find(item => item.session === message.session);
480
+ if (message.type === 'usage-arm') {
481
+ diagnostics?.record('arm', connection?.userId ? 'account-linked' : 'anonymous');
482
+ if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
483
+ (message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
484
+ diagnostics?.record('arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
485
+ return;
486
+ }
487
+ for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
488
+ if (room.usageArms.size >= 256) return;
489
+ room.usageArms.set(armKey, { userId: connection.userId,
490
+ clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
491
+ session: message.session, at: Date.now() });
492
+ diagnostics?.record('arm-accepted', 'ready');
493
+ return;
494
+ }
495
+ if (message.type === 'usage-cancel') {
496
+ diagnostics?.record('message-cancelled', room.usageArms.has(armKey) ? 'armed' : 'arm-missing');
497
+ room.usageArms.delete(armKey); return;
498
+ }
499
+ if (message.type === 'usage-message') {
500
+ const arm = room.usageArms.get(armKey);
501
+ diagnostics?.record('message', arm ? 'armed' : 'arm-missing');
502
+ room.usageArms.delete(armKey);
503
+ if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) {
504
+ diagnostics?.record('message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
505
+ return;
506
+ }
507
+ try {
508
+ await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
509
+ diagnostics?.record('message-counted', 'confirmed');
510
+ if (message.turnKind === 'start' && plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
511
+ const key = `${message.threadId}:${message.turnId}`;
512
+ if (key.length <= 128 && !room.usageTasks.has(key)) {
513
+ const task = { userId: arm.userId, clientId: arm.clientId };
514
+ await meter.action(task, room.id, arm.clientId, 'turn', key);
515
+ diagnostics?.record('turn-counted', 'confirmed');
516
+ if (message.observerReady === true) {
517
+ await meter.pulse(task, true, room.id, arm.clientId, key);
518
+ if (message.completed) await meter.pulse(task, false, room.id, arm.clientId);
519
+ else room.usageTasks.set(key, task);
520
+ }
521
+ }
522
+ }
523
+ } catch (error) { diagnostics?.record('message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
524
+ return;
525
+ }
526
+ if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
527
+ !plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
528
+ const key = `${message.threadId}:${message.turnId}`;
529
+ if (key.length > 128) return;
530
+ let task = room.usageTasks.get(key);
531
+ try {
532
+ if (message.method === 'resume' && !task) {
533
+ task = await meter.turnOrigin(room.id, key);
534
+ if (!task) { diagnostics?.record('turn-resume', 'origin-missing'); return; }
535
+ await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
536
+ diagnostics?.record('turn-resume', 'confirmed');
537
+ room.usageTasks.set(key, task);
538
+ } else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
539
+ else if (task && message.method === 'turn/completed') {
540
+ await meter.pulse(task, false, room.id, task.clientId);
541
+ diagnostics?.record('turn-completed', 'confirmed');
542
+ room.usageTasks.delete(key);
543
+ }
544
+ } catch (error) { log({ event: 'usage-error', message: error.message }); }
545
+ return;
546
+ }
405
547
  if (message.type === 'lan-candidates') {
406
548
  room.lanCandidates = normalizeLanCandidates(message.candidates);
407
549
  for (const connection of room.connections.values()) send(connection.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
@@ -0,0 +1,46 @@
1
+ const MAX_BATCH = 200;
2
+
3
+ export function usageDiagnostics(pool, onError = () => {}) {
4
+ let tail = Promise.resolve();
5
+ function serialize(action) {
6
+ const result = tail.then(action);
7
+ tail = result.catch(error => { onError(error); });
8
+ return result;
9
+ }
10
+ function record(event, detail) {
11
+ if (!/^[a-z-]{1,32}$/.test(event) || !/^[a-z-]{1,64}$/.test(detail)) throw new Error('Invalid usage diagnostic');
12
+ void serialize(async () => {
13
+ await pool.execute('INSERT INTO relay_usage_diagnostics (created_at,event,detail) VALUES (UTC_TIMESTAMP(3),?,?)', [event, detail]);
14
+ await pool.execute('DELETE FROM relay_usage_diagnostics WHERE created_at < UTC_TIMESTAMP(3) - INTERVAL 1 DAY');
15
+ }).catch(() => {});
16
+ }
17
+ async function read() {
18
+ return serialize(async () => {
19
+ const [rows] = await pool.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
20
+ ORDER BY id LIMIT ${MAX_BATCH + 1}`);
21
+ return { rows: rows.slice(0, MAX_BATCH), hasMore: rows.length > MAX_BATCH };
22
+ });
23
+ }
24
+ async function clear() {
25
+ return serialize(async () => {
26
+ const [result] = await pool.query('DELETE FROM relay_usage_diagnostics');
27
+ return { cleared: result.affectedRows };
28
+ });
29
+ }
30
+ async function drain() {
31
+ return serialize(async () => {
32
+ const db = await pool.getConnection();
33
+ try {
34
+ await db.beginTransaction();
35
+ const [rows] = await db.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
36
+ ORDER BY id LIMIT ${MAX_BATCH + 1} FOR UPDATE`);
37
+ const selected = rows.slice(0, MAX_BATCH);
38
+ if (selected.length) await db.execute('DELETE FROM relay_usage_diagnostics WHERE id <= ?', [selected.at(-1).id]);
39
+ await db.commit();
40
+ return { rows: selected, hasMore: rows.length > MAX_BATCH };
41
+ } catch (error) { await db.rollback(); throw error; }
42
+ finally { db.release(); }
43
+ });
44
+ }
45
+ return { record, read, clear, drain };
46
+ }
@@ -0,0 +1,125 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ const epoch = value => new Date(`${value.replace(' ', 'T')}Z`).getTime();
4
+
5
+ // Count confirmed intervals only; a lost connection never creates estimated time.
6
+ export function usageMeter(pool) {
7
+ async function action(connection, computerId, clientId, kind, key) {
8
+ if (!connection.userId || !['message', 'turn'].includes(kind) || typeof key !== 'string' || !key || key.length > 128) return;
9
+ const column = kind === 'message' ? 'message_count' : 'turn_count';
10
+ const db = await pool.getConnection();
11
+ try {
12
+ await db.beginTransaction();
13
+ const [result] = await db.execute(`INSERT IGNORE INTO relay_usage_actions
14
+ (computer_id,kind,action_key,user_id,client_id,occurred_at)
15
+ VALUES (?,?,?,?,?,UTC_TIMESTAMP(3))`,
16
+ [computerId, kind, key, connection.userId, clientId]);
17
+ if (result.affectedRows === 1) {
18
+ await db.execute(`INSERT INTO relay_usage_totals (user_id,${column},updated_at)
19
+ VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
20
+ updated_at=VALUES(updated_at)`, [connection.userId]);
21
+ await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,${column},updated_at)
22
+ VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
23
+ updated_at=VALUES(updated_at)`, [computerId]);
24
+ }
25
+ await db.commit();
26
+ return result.affectedRows === 1;
27
+ } catch (error) { await db.rollback(); throw error; }
28
+ finally { db.release(); }
29
+ }
30
+ async function turnOrigin(computerId, key) {
31
+ const [rows] = await pool.execute(`SELECT user_id,client_id FROM relay_usage_actions
32
+ WHERE computer_id=? AND kind='turn' AND action_key=?`, [computerId, key]);
33
+ return rows[0] ? { userId: rows[0].user_id, clientId: rows[0].client_id } : null;
34
+ }
35
+ async function pulse(connection, active, computerId, clientId, externalKey = null) {
36
+ if (!connection.userId) return;
37
+ if (!active && !connection.usageSessionId) return;
38
+ if (active && !connection.usageSessionId) {
39
+ const id = randomUUID();
40
+ await pool.execute(`INSERT INTO relay_usage_sessions
41
+ (id,kind,external_key,user_id,computer_id,client_id,started_at,confirmed_until,charged_seconds)
42
+ VALUES (?,'task',?,?,?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3),0)`, [id, externalKey, connection.userId, computerId, clientId]);
43
+ connection.usageSessionId = id;
44
+ return;
45
+ }
46
+ const id = connection.usageSessionId;
47
+ const db = await pool.getConnection();
48
+ try {
49
+ await db.beginTransaction();
50
+ const [rows] = await db.execute('SELECT user_id,computer_id,confirmed_until,ended_at FROM relay_usage_sessions WHERE id=? FOR UPDATE', [id]);
51
+ const row = rows[0];
52
+ if (!row || row.ended_at) throw new Error('Invalid relay usage session');
53
+ const [clock] = await db.query('SELECT UTC_TIMESTAMP(3) AS now');
54
+ const now = clock[0].now;
55
+ const elapsedMs = Math.max(0, epoch(now) - epoch(row.confirmed_until));
56
+ const seconds = Math.min(elapsedMs, 20000) / 1000;
57
+ const [plans] = await db.execute('SELECT tier,valid_until FROM relay_usage_entitlements WHERE user_id=?', [row.user_id]);
58
+ const subscribed = plans[0]?.tier === 'subscriber' && plans[0].valid_until && epoch(plans[0].valid_until) > epoch(now);
59
+ const freeSeconds = subscribed ? 0 : seconds;
60
+ await db.execute(`UPDATE relay_usage_sessions SET confirmed_until=?,ended_at=?,charged_seconds=charged_seconds+?,
61
+ free_charged_seconds=free_charged_seconds+? WHERE id=?`, [now, active ? null : now, seconds, freeSeconds, id]);
62
+ if (seconds > 0) {
63
+ await db.execute(`INSERT INTO relay_usage_totals (user_id,task_seconds,updated_at)
64
+ VALUES (?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
65
+ updated_at=VALUES(updated_at)`, [row.user_id, seconds, now]);
66
+ await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,task_seconds,free_task_seconds,updated_at)
67
+ VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
68
+ free_task_seconds=free_task_seconds+VALUES(free_task_seconds),updated_at=VALUES(updated_at)`,
69
+ [row.computer_id, seconds, freeSeconds, now]);
70
+ }
71
+ await db.commit();
72
+ if (!active) connection.usageSessionId = undefined;
73
+ } catch (error) { await db.rollback(); throw error; }
74
+ finally { db.release(); }
75
+ }
76
+
77
+ async function lost(connection) {
78
+ const id = connection.usageSessionId;
79
+ connection.usageSessionId = undefined;
80
+ if (!id) return;
81
+ await pool.execute(`UPDATE relay_usage_sessions SET ended_at=confirmed_until
82
+ WHERE id=? AND ended_at IS NULL`, [id]);
83
+ }
84
+
85
+ return { action, turnOrigin, pulse, lost };
86
+ }
87
+
88
+ export async function usageReport(pool, page, userId = '') {
89
+ if (userId) {
90
+ const [users] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
91
+ COALESCE(t.task_seconds,0) AS task_seconds,
92
+ COALESCE(t.message_count,0) AS message_count,COALESCE(t.turn_count,0) AS turn_count
93
+ FROM users u JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
94
+ LEFT JOIN relay_usage_totals t ON t.user_id=u.id WHERE u.id=?`, [userId]);
95
+ if (!users.length) return null;
96
+ const [computers] = await pool.execute(`SELECT c.id AS computer_id,
97
+ COALESCE(s.task_seconds,0) AS task_seconds,
98
+ COALESCE(a.message_count,0) AS message_count,
99
+ COALESCE(a.turn_count,0) AS turn_count,
100
+ COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
101
+ FROM computers c
102
+ LEFT JOIN (SELECT computer_id,
103
+ SUM(charged_seconds) AS task_seconds,
104
+ MAX(started_at) AS last_used_at FROM relay_usage_sessions WHERE user_id=? GROUP BY computer_id) s ON s.computer_id=c.id
105
+ LEFT JOIN (SELECT computer_id,
106
+ SUM(kind='message') AS message_count,SUM(kind='turn') AS turn_count,
107
+ MAX(occurred_at) AS last_used_at
108
+ FROM relay_usage_actions WHERE user_id=? GROUP BY computer_id) a ON a.computer_id=c.id
109
+ WHERE s.computer_id IS NOT NULL OR a.computer_id IS NOT NULL
110
+ ORDER BY last_used_at DESC`, [userId, userId]);
111
+ return { user: users[0], computers };
112
+ }
113
+ const offset = (page - 1) * 30;
114
+ const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM user_login_identities WHERE kind='email'`);
115
+ const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
116
+ COALESCE(t.task_seconds,0) AS task_seconds,
117
+ COALESCE(t.message_count,0) AS message_count,COALESCE(t.turn_count,0) AS turn_count,
118
+ COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
119
+ FROM users u JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
120
+ LEFT JOIN relay_usage_totals t ON t.user_id=u.id
121
+ LEFT JOIN (SELECT user_id,MAX(started_at) AS last_used_at FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
122
+ LEFT JOIN (SELECT user_id,MAX(occurred_at) AS last_used_at FROM relay_usage_actions GROUP BY user_id) a ON a.user_id=u.id
123
+ ORDER BY task_seconds DESC,last_used_at DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
124
+ return { page, pageSize: 30, total: counts[0].total, rows };
125
+ }
@@ -283,7 +283,7 @@ async function handleRequest(request, response) {
283
283
  return;
284
284
  }
285
285
 
286
- if (request.method === 'GET' && url.pathname === '/api/usage-events') {
286
+ if (request.method === 'GET' && url.pathname === '/api/usage-events') {
287
287
  const execution = sharedServer;
288
288
  response.startEvents();
289
289
  const emit = value => { if (!response.destroyed) response.event('usage', value); };
@@ -292,8 +292,45 @@ async function handleRequest(request, response) {
292
292
  execution.on('usage', emit); execution.on('usage-error', failed); execution.on('disconnected', disconnected);
293
293
  response.once('close', () => { execution.off('usage', emit); execution.off('usage-error', failed); execution.off('disconnected', disconnected); });
294
294
  try { emit(await execution.usage()); } catch (error) { failed(error); }
295
- return;
296
- }
295
+ return;
296
+ }
297
+
298
+ if (request.method === 'GET' && url.pathname === '/api/turn-events') {
299
+ response.startEvents();
300
+ const listener = ({ method, params }) => {
301
+ if (!['turn/started', 'turn/completed'].includes(method)) return;
302
+ const threadId = params?.threadId, turnId = params?.turn?.id;
303
+ if (typeof threadId === 'string' && typeof turnId === 'string' && !response.destroyed) {
304
+ response.event('turn', { method, threadId, turnId });
305
+ }
306
+ };
307
+ const disconnected = () => response.end();
308
+ sharedServer.on('notification', listener);
309
+ sharedServer.on('disconnected', disconnected);
310
+ response.once('close', () => {
311
+ sharedServer.off('notification', listener);
312
+ sharedServer.off('disconnected', disconnected);
313
+ });
314
+ return;
315
+ }
316
+
317
+ const activeTurnMatch = /^\/api\/threads\/([^/]+)\/active-turn$/.exec(url.pathname);
318
+ if (request.method === 'GET' && activeTurnMatch) {
319
+ const threadId = decodeURIComponent(activeTurnMatch[1]);
320
+ await sharedServer.connect();
321
+ const { thread } = await sharedServer.request('thread/read', { threadId, includeTurns: false });
322
+ const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
323
+ let turnId = null;
324
+ if (status === 'active') {
325
+ const page = await sharedServer.request('thread/turns/list', {
326
+ threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
327
+ });
328
+ const latest = page.data?.[0];
329
+ if (latest?.status === 'inProgress') turnId = latest.id;
330
+ }
331
+ sendJson(response, 200, { turnId });
332
+ return;
333
+ }
297
334
 
298
335
  const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
299
336
  if (request.method === 'POST' && createThreadMatch) {
@@ -473,7 +510,8 @@ async function handleRequest(request, response) {
473
510
  await requireExecutionWritable(threadId);
474
511
  const body = await readJsonBody(request);
475
512
  if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
476
- sendJson(response, 200, { success: true, data: await sharedServer.send(appServerTurnArguments(threadId, body)) });
513
+ const submitted = await sharedServer.send(appServerTurnArguments(threadId, body));
514
+ sendJson(response, 200, { success: true, data: submitted.result, usage: submitted.usage });
477
515
  return;
478
516
  }
479
517
 
@@ -711,12 +749,12 @@ function messagePayload(body) {
711
749
  return payload;
712
750
  }
713
751
 
714
- function appServerTurnArguments(threadId, body) {
752
+ function appServerTurnArguments(threadId, body) {
715
753
  const payload = messagePayload(body);
716
754
  const input = [];
717
755
  if (payload.prompt) input.push({ type: 'text', text: payload.prompt });
718
756
  for (const image of payload.images) input.push(image.startsWith('data:') ? { type: 'image', url: image } : { type: 'localImage', path: image });
719
- const value = { threadId, input };
757
+ const value = { threadId, input, ...appServerEvents.mobileTurnSource(body.clientUserMessageId) };
720
758
  if (typeof body.clientUserMessageId === 'string' && /^[a-f0-9]{32}$/.test(body.clientUserMessageId)) value.clientUserMessageId = body.clientUserMessageId;
721
759
  if (payload.model) value.model = payload.model;
722
760
  if (payload.thinking) value.effort = payload.thinking;
@@ -136,15 +136,18 @@ export class SharedAppServer extends EventEmitter {
136
136
  // Reading the live thread checks this service owns it without resume changing settings.
137
137
  const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
138
138
  const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
139
- if (status === 'active') {
139
+ if (status === 'active') {
140
140
  const page = await this.request('thread/turns/list', {
141
141
  threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
142
142
  });
143
143
  const active = page.data?.[0];
144
144
  if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
145
- return this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
146
- }
147
- return this.request('turn/start', args);
145
+ const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId);
146
+ const result = await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
147
+ return { result, usage: { kind: 'steer', turnId: active.id } };
148
+ }
149
+ const result = await this.request('turn/start', args);
150
+ return { result, usage: { kind: 'start', turnId: result?.turn?.id || null } };
148
151
  }
149
152
 
150
153
  respondApproval(threadId, body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.17",
3
+ "version": "0.1.0-beta.19",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {