diff --git a/docs/ZHIZI_CLOUD_ENGINE.md b/docs/ZHIZI_CLOUD_ENGINE.md index 314e781..bca0fa6 100644 --- a/docs/ZHIZI_CLOUD_ENGINE.md +++ b/docs/ZHIZI_CLOUD_ENGINE.md @@ -18,16 +18,16 @@ GoAgent -> 智子云登录 -> Socket.IO 远程 KataGo -> GTP kata-analyze 在 GoAgent 设置页打开“分析引擎”: -1. 引擎模式选择 `智子云远程算力:直连`。 -2. 算力类型默认选择 `VIP 共享引擎`,GoAgent 会提交 `--gpu-type vip-share`。这是智子管理员确认的 VIP 包月引擎,不会额外按时间扣独享费用。 +1. 算力类型默认选择 `VIP 共享引擎`,GoAgent 会提交 `--gpu-type vip-share`。这是智子管理员确认的 VIP 包月引擎,不会额外按时间扣独享费用。 - `独享 1x / 3x / 6x` 对应 `--gpu-type 1x / 3x / 6x`,属于按算力和时间计费的独享 worker。 - 如果你只是购买了 VIP,请不要选择独享档位。 -3. 填写智子云主账号手机号或邮箱和密码,点击“登录并连接智子云”。 +2. 填写智子云主账号手机号或邮箱和密码,点击“登录智子云”。 - GoAgent 会调用智子云登录接口获取 token。 - 登录成功后,GoAgent 会把 token 保存到本地加密存储。 - - 登录成功后,引擎模式会自动切到 `智子云远程算力:直连`。 + - 登录本身不会中断当前本机分析,也不会立即上传局面。 - 后续只需要启动 GoAgent,不需要先启动其它应用。 - `zz-` 开头的是智子连接账号,不是主账号登录 token。它需要先在智子官方账号体系里绑定,不能直接填在 GoAgent 的登录框里换取远程算力 token。 +3. 点击“检测并启用”。GoAgent 会真实启动远程 KataGo,并等待候选点、胜率、目差和实时搜索速度返回。只有检测通过后才切换到智子云;检测失败时仍保持本机分析。 4. 如果密码登录提示密码不正确,可以点击“发送验证码”,收到短信后用“验证码登录”。 - 这和智子 Web 端的 `send-code` / `fast-login` 流程一致。 - 验证码只用于本次登录;GoAgent 不会长期保存验证码。 @@ -37,6 +37,15 @@ GoAgent -> 智子云登录 -> Socket.IO 远程 KataGo -> GTP kata-analyze `自动` 模式现在是本机优先的默认模式,不会因为本机分析失败就自动上传局面到智子云。需要远程算力时,请手动启用 `智子云远程算力:直连`。 +## 连接与分析机制 + +- 检测通过后,GoAgent 会复用同一个远程 Socket.IO / GTP 会话,避免每次切换手数都重新排队启动 worker。 +- Socket 断线时会自动重连;局面同步或分析过程中断线,会重新建立会话并重新同步当前局面,旧结果不会覆盖新局面。 +- 分析按真实 `visits` 达到目标后停止,不再用固定等待时间猜测分析是否完成。 +- `stdout` / `stderr` 的字符串、Buffer、ArrayBuffer 和 Socket.IO Buffer JSON 都会按 UTF-8 解码。 +- VIP 共享会话空闲 5 分钟后释放;独享 1x / 3x / 6x 空闲 90 秒后释放,减少无效占用。 +- 切回本机分析或退出登录时,会立即停止任务并释放远程会话。 + ## 退出和重新登录 如果刚购买套餐、充值或修改了智子云账号状态,但 GoAgent 仍提示额度不足或远程算力不可用,可以在设置页点击 `退出智子云登录`。GoAgent 会: @@ -45,7 +54,7 @@ GoAgent -> 智子云登录 -> Socket.IO 远程 KataGo -> GTP kata-analyze 2. 清除本地保存的智子云 token。 3. 自动切回 `自动` 分析模式,并关闭“本机慢时自动使用智子云”,避免旧 token 或旧连接器路径继续重试。 -然后使用账号密码或短信验证码重新登录。重新登录成功后,GoAgent 会自动切回 `智子云远程算力:直连`。 +然后使用账号密码或短信验证码重新登录,再点击“检测并启用”。 ## 和 iKataGo 的区别 @@ -72,8 +81,9 @@ GoAgent 不会把智子 token 写入普通设置文件,也不会在日志中 - `这是智子云连接账号,不是可直接登录的主账号`:请改用智子云主账号手机号或邮箱登录。`zz-` 连接账号不能直接换取 GoAgent 远程算力 token。 - `密码不正确`:智子官方密码接口拒绝了这组密码。可以改用“发送验证码”再“验证码登录”。 - `验证码不正确或已过期`:重新发送验证码后再登录。 -- `智子云远程 worker 返回额度不足或不可分配`:如果你是 VIP 用户,请先确认算力类型选择的是 `VIP 共享引擎(--gpu-type vip-share)`。如果选择 `1x / 3x / 6x`,智子云会按独享 worker 计费,并可能因为独享余额不足返回 `not_enough_credit`。如果刚充值或刚购买 VIP,点击 `退出智子云登录` 后重新登录,让 GoAgent 刷新 token 和远程会话。 -- 如果检测连接显示 `主账号 token 有效`、`会员有效`、`连接账号已绑定`,但远端仍返回 `not_enough_credit`,说明 GoAgent 已完成官方公开流程:`fetch-socketio-token` 成功、Socket.IO 收到 `ready`,但智子 worker 在执行 GTP 初始化时拒绝分配算力。此时请把这段状态反馈给智子官方,让官方检查连接账号的远程 worker/GPU 权益是否已经实际生效。 +- `当前没有空闲算力`:GoAgent 会按有限次数自动重试。这通常是 worker 暂时繁忙,不等于账号余额不足。 +- VIP 共享返回 `not_enough_credit`:GoAgent 会明确提示“VIP 权益/连接账号未同步”,不会把它误报成独享余额不足。先退出并重新登录;持续失败时请让智子官方检查 VIP 与连接账号的 worker 权益。 +- 独享 1x / 3x / 6x 返回 `not_enough_credit`:这是按量档位的余额或档位问题,请在智子官方 App 检查。 - 官方 Postman 中的 `connectAccount/login` 只用于验证连接账号凭据,不返回 GoAgent 直连所需的 token,也不会替代主账号登录流程。 - `智子云 KataGo 启动超时`:检查 token 是否有效,或在 GoAgent 中重新登录。 - `智子云 GTP 命令失败`:可能是远端引擎尚未 ready、账号状态异常,或智子云当前连接不可用。 diff --git a/package.json b/package.json index d942aa4..877e3c9 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "check:artifacts": "node scripts/package_artifact_smoke.mjs --mode=dev", "smoke:teacher-llm": "pnpm build && node scripts/teacher_llm_smoke.mjs", "smoke:teacher-llm:real": "pnpm build && node scripts/teacher_llm_real_smoke.mjs", + "smoke:zhizi-remote": "node scripts/smoke_zhizi_remote.mjs", "rc:check": "node scripts/p0_release_candidate_check.mjs --mode=dev", "rc:artifacts": "node scripts/verify_release_artifacts.mjs --mode=dev", "rc:evidence": "node scripts/collect_release_evidence.mjs --mode=dev" diff --git a/scripts/smoke_zhizi_remote.mjs b/scripts/smoke_zhizi_remote.mjs new file mode 100644 index 0000000..ab6e70b --- /dev/null +++ b/scripts/smoke_zhizi_remote.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +import { createDecipheriv, scryptSync } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' + +import { + buildZhiziRemoteArgs, + parseKataAnalyzeInfo, + zhiziAnalysisReachedVisits +} from '../src/main/services/zhiziGtpProtocol.ts' +import { ZhiziPersistentSession } from '../src/main/services/zhiziSocketSession.ts' + +const strict = process.env.GOAGENT_ZHIZI_REAL === '1' +const appHome = process.env.GOAGENT_APP_HOME || join(os.homedir(), '.goagent') +const settingsPath = join(appHome, 'settings.json') +const secretsPath = join(appHome, 'secrets.json') +const keyPath = join(appHome, 'secrets.key') + +function skipped(reason) { + console.log(JSON.stringify({ status: 'skipped', reason }, null, 2)) + if (strict) process.exitCode = 1 +} + +function decryptLocalSecret(secret) { + if (!secret || secret.mode !== 'local-v1') return '' + const seed = readFileSync(keyPath, 'utf8').trim() + const key = scryptSync(seed, 'goagent-local-secret-store-v1', 32) + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(secret.iv, 'base64')) + decipher.setAuthTag(Buffer.from(secret.tag, 'base64')) + return Buffer.concat([ + decipher.update(Buffer.from(secret.value, 'base64')), + decipher.final() + ]).toString('utf8') +} + +function responseCount(text) { + return (text.match(/\r?\n\r?\n/g) ?? []).length +} + +async function sendSetup(session, signal, commands) { + const start = session.output().stdout.length + const disconnectVersion = session.captureDisconnectVersion() + session.send(`${commands.join('\n')}\n`) + await session.waitUntil( + () => responseCount(session.output().stdout.slice(start)) >= commands.length, + 15_000, + 'Zhizi smoke setup', + signal, + disconnectVersion + ) + const output = session.output().stdout.slice(start) + if (/(?:^|\n)\?/.test(output.trim())) { + throw new Error(`GTP setup rejected: ${output.trim().slice(0, 300)}`) + } +} + +async function analyze(session, signal, id, moves) { + session.clearOutput() + await sendSetup(session, signal, [ + 'boardsize 19', + 'kata-set-rules chinese', + 'komi 7.5', + 'clear_board', + ...moves.map(([color, point]) => `play ${color} ${point}`) + ]) + const start = session.output().stdout.length + const disconnectVersion = session.captureDisconnectVersion() + const player = moves.at(-1)?.[0] === 'B' ? 'W' : 'B' + session.send(`kata-analyze ${player} 25 rootInfo true maxmoves 8\n`) + let response + const startedAt = Date.now() + await session.waitUntil( + () => { + const output = session.output().stdout.slice(start) + if (!/\binfo\s+/.test(output)) return false + const parsed = parseKataAnalyzeInfo(output, player, id) + if (!parsed.moveInfos?.length) return false + response = parsed + return zhiziAnalysisReachedVisits(parsed, 64) + }, + 30_000, + 'Zhizi smoke analysis', + signal, + disconnectVersion + ) + session.send('stop\n') + await session.waitUntil( + () => responseCount(session.output().stdout.slice(start)) >= 1, + 10_000, + 'Zhizi smoke stop', + signal, + disconnectVersion + ) + return { + id, + elapsedMillis: Date.now() - startedAt, + topMove: response.moveInfos[0].move, + visits: response.moveInfos.reduce((sum, move) => sum + Number(move.visits ?? 0), 0), + winrate: response.rootInfo?.winrate, + scoreLead: response.rootInfo?.scoreLead + } +} + +if (!strict) { + skipped('Set GOAGENT_ZHIZI_REAL=1 to run the real Zhizi cloud smoke test.') +} else if (![settingsPath, secretsPath, keyPath].every(existsSync)) { + skipped('GoAgent local settings or secret files are missing.') +} else { + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const secrets = JSON.parse(readFileSync(secretsPath, 'utf8')) + const accountToken = decryptLocalSecret(secrets.zhiziToken) + if (!accountToken) { + skipped('No locally saved Zhizi token is available.') + } else { + const gpuType = String(settings.zhiziGpuType || 'vip-share') + const args = buildZhiziRemoteArgs({ + zhiziGpuType: gpuType, + zhiziExtraArgs: String(settings.zhiziExtraArgs || '') + }) + const session = new ZhiziPersistentSession({ accountToken, args, gpuType }) + const controller = new AbortController() + try { + const results = await session.runExclusive(controller.signal, async (channel) => [ + await analyze(channel, controller.signal, 'smoke-1', [ + ['B', 'D4'], + ['W', 'Q16'], + ['B', 'Q4'] + ]), + await analyze(channel, controller.signal, 'smoke-2', [ + ['B', 'D4'], + ['W', 'Q16'], + ['B', 'Q4'], + ['W', 'D16'] + ]) + ]) + const telemetry = session.telemetry() + console.log(JSON.stringify({ + status: 'passed', + gpuType, + connectionCount: telemetry.connectionCount, + sessionReadyMillis: telemetry.lastReadyMillis, + results + }, null, 2)) + if (telemetry.connectionCount !== 1) { + throw new Error(`Expected one persistent connection, received ${telemetry.connectionCount}.`) + } + } catch (error) { + console.error(JSON.stringify({ + status: 'failed', + error: error instanceof Error ? error.message : String(error) + }, null, 2)) + process.exitCode = 1 + } finally { + session.close() + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 55054e9..99d85af 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,7 +12,7 @@ import { listLlmModels, testLlmSettings } from './services/llm' import { analyzeTrialPositionWithProgress, cancelKataGoAnalysis } from './services/katago' import { benchmarkKataGo } from './services/katagoBenchmark' import { getKataGoEnginePoolStats } from './services/katagoEnginePool' -import { getAnalysisSchedulerStats, runScheduledAnalysis } from './services/analysis/scheduler' +import { cancelScheduledAnalysis, getAnalysisSchedulerStats, runScheduledAnalysis } from './services/analysis/scheduler' import { analyzeGameQuickRuntime, analyzePositionRuntime, analyzePositionWithProgressRuntime } from './services/analysis/runtimeIntegration' import { collectDiagnostics } from './services/diagnostics' import { searchKnowledgeCards } from './services/knowledge/searchLocal' @@ -30,8 +30,9 @@ import { } from './services/studentProfile' import { archiveTeacherSession, createTeacherSession, deleteTeacherSession, getActiveTeacherSession, listTeacherSessions, updateTeacherSessionMessages } from './services/teacherSession' import { clearTtsCache, inspectTtsAssets, listTtsVoices, synthesizeTts, testTtsSettings } from './services/tts' -import { getZhiziCloudAccountStatus, loginZhiziCloudByCode, loginZhiziCloudByPassword, sendZhiziCloudLoginCode } from './services/zhiziCloudAuth' -import { queryZhiziGtpAnalysisBatch } from './services/zhiziGtpEngine' +import { loginZhiziCloudByCode, loginZhiziCloudByPassword, sendZhiziCloudLoginCode } from './services/zhiziCloudAuth' +import { probeZhiziCloudConnection } from './services/zhiziConnectionProbe' +import { resetZhiziPersistentSession } from './services/zhiziSocketSession' let mainWindow: BrowserWindow | null = null type DesktopCommand = @@ -117,21 +118,13 @@ function requestTeacherBoardImages(event: IpcMainInvokeEvent, request: TeacherBo }) } -function humanizeZhiziConnectionError(error: unknown): string { - const text = error instanceof Error ? error.message : String(error) - if (/not_enough_credit|余额不足|not enough credit|没有可用算力/i.test(text)) { - return '智子云登录有效,但远程 worker 返回 not_enough_credit。请在智子官方 App 确认远程算力权益已经开通,并且已同步到当前连接账号;普通会员有效不一定代表远程 GPU worker 已可分配。' - } - if (/invalid_status|NoSuchKey|ssh\.json|colab/i.test(text)) { - return '智子云远程平台没有找到当前账号的可用 worker 配置。请确认该账号已开通对应远程算力。' - } - if (/token 已失效|401|invalid.*token|auth/i.test(text)) { - return '智子云 token 已失效,请退出后重新登录智子云。' - } - if (/websocket error|Socket 连接失败|Socket 已断开|transport error|xhr poll error|timeout|超时/i.test(text)) { - return `智子云 Socket 连接失败:${text.slice(0, 260)}` - } - return `智子云连接检测失败:${text.slice(0, 320)}` +function zhiziSessionConfigurationChanged(before: AppSettings, after: AppSettings): boolean { + return ( + before.zhiziToken !== after.zhiziToken || + before.zhiziGpuType !== after.zhiziGpuType || + before.zhiziExtraArgs !== after.zhiziExtraArgs || + (before.katagoEngineMode === 'zhizi' && after.katagoEngineMode !== 'zhizi') + ) } function attachTextEditingContextMenu(window: BrowserWindow): void { @@ -313,7 +306,12 @@ app.whenReady().then(() => { ipcMain.handle('dashboard:get', async () => dashboard()) ipcMain.handle('settings:update', async (_event, payload: Partial) => { - setSettings(payload) + const before = getSettings() + const after = setSettings(payload) + if (zhiziSessionConfigurationChanged(before, after)) { + cancelKataGoAnalysis({}) + resetZhiziPersistentSession() + } return dashboard() }) @@ -513,7 +511,7 @@ app.whenReady().then(() => { } }) ipcMain.handle('katago:cancel-analysis', async (_event, payload: KataGoCancelAnalysisRequest) => - cancelKataGoAnalysis(payload) + cancelScheduledAnalysis(payload) ) ipcMain.handle('analysis-scheduler:stats', async () => getAnalysisSchedulerStats()) ipcMain.handle('katago:engine-pool-stats', async () => getKataGoEnginePoolStats()) @@ -547,14 +545,14 @@ app.whenReady().then(() => { })) ipcMain.handle('zhizi:login-password', async (_event, payload: ZhiziCloudLoginRequest): Promise => { const result = await loginZhiziCloudByPassword(payload) + resetZhiziPersistentSession() setSettings({ zhiziUsername: payload.phone.trim(), - zhiziToken: result.token, - katagoEngineMode: 'zhizi' + zhiziToken: result.token }) return { ok: true, - message: `${result.message} GoAgent 可直接连接智子云。`, + message: `${result.message} 请点击“检测并启用”,确认远程引擎真正可用。`, hasToken: true, dashboard: await dashboard() } @@ -568,20 +566,21 @@ app.whenReady().then(() => { }) ipcMain.handle('zhizi:login-code', async (_event, payload: ZhiziCloudLoginCodeRequest): Promise => { const result = await loginZhiziCloudByCode(payload) + resetZhiziPersistentSession() setSettings({ zhiziUsername: payload.phone.trim(), - zhiziToken: result.token, - katagoEngineMode: 'zhizi' + zhiziToken: result.token }) return { ok: true, - message: `${result.message} GoAgent 可直接连接智子云。`, + message: `${result.message} 请点击“检测并启用”,确认远程引擎真正可用。`, hasToken: true, dashboard: await dashboard() } }) ipcMain.handle('zhizi:logout', async (): Promise => { cancelKataGoAnalysis({}) + resetZhiziPersistentSession() setSettings({ zhiziToken: '', katagoEngineMode: 'auto', @@ -595,71 +594,19 @@ app.whenReady().then(() => { } }) ipcMain.handle('zhizi:test-connection', async (): Promise => { - const settings = getSettings() - if (!settings.zhiziToken.trim()) { - return { - ok: false, - message: '智子云未登录:请先用账号密码或短信验证码登录。' - } - } - const accountStatus = await getZhiziCloudAccountStatus(settings.zhiziToken) - if (!accountStatus.tokenValid) { - return { - ok: false, - message: '智子云 token 已失效,请重新登录。', - ...accountStatus - } - } - try { - const results = await queryZhiziGtpAnalysisBatch({ - settings: { - ...settings, - katagoEngineMode: 'zhizi', - zhiziClientBin: '' - }, - runId: `zhizi-smoke-${Date.now()}`, - group: 'quick', - timeoutMs: 120_000, - queries: [ - { - id: 'zhizi-smoke', - boardXSize: 19, - boardYSize: 19, - komi: 7.5, - initialPlayer: 'B', - moves: [ - ['B', 'D4'], - ['W', 'Q16'], - ['B', 'Q4'] - ], - maxVisits: 48 - } - ] - }) - const result = results.get('zhizi-smoke') - const best = result?.moveInfos?.[0] - if (!best) { - return { - ok: false, - message: '智子云已连接,但没有返回候选点。请稍后重试。' - } - } - return { - ok: true, - message: '智子云连接成功,远程 KataGo 已返回候选点。', - candidateCount: result?.moveInfos?.length ?? 0, - topMove: typeof best.move === 'string' ? best.move : undefined, - visits: typeof best.visits === 'number' ? best.visits : undefined, - winrate: typeof best.winrate === 'number' ? best.winrate : undefined, - scoreMean: typeof best.scoreMean === 'number' ? best.scoreMean : typeof best.scoreLead === 'number' ? best.scoreLead : undefined, - ...accountStatus - } - } catch (error) { - return { - ok: false, - message: humanizeZhiziConnectionError(error), - ...accountStatus - } + return probeZhiziCloudConnection(getSettings()) + }) + ipcMain.handle('zhizi:enable', async (): Promise => { + const result = await probeZhiziCloudConnection(getSettings()) + if (!result.ok) return result + setSettings({ + katagoEngineMode: 'zhizi', + zhiziUseWhenLocalSlow: false + }) + return { + ...result, + message: `${result.message} 已切换为智子云分析。`, + dashboard: await dashboard() } }) ipcMain.handle('tts:inspect-assets', async () => inspectTtsAssets()) @@ -704,3 +651,7 @@ app.on('window-all-closed', () => { app.quit() } }) + +app.on('before-quit', () => { + resetZhiziPersistentSession() +}) diff --git a/src/main/lib/store.ts b/src/main/lib/store.ts index 835b446..b56d51a 100644 --- a/src/main/lib/store.ts +++ b/src/main/lib/store.ts @@ -301,8 +301,18 @@ function migrateLocalAnalysisDefault(settings: AppSettings): AppSettings { return migrated } +function migrateZhiziLoginIdentifier(settings: AppSettings): AppSettings { + if (!/^zz[-_]/i.test(settings.zhiziUsername.trim())) { + return settings + } + settingsStore.set('zhiziUsername', '') + return { ...settings, zhiziUsername: '' } +} + export function getSettings(): AppSettings { - const persisted = migrateLocalAnalysisDefault(migratePlaintextSecrets({ ...defaults, ...settingsStore.store })) + const persisted = migrateZhiziLoginIdentifier( + migrateLocalAnalysisDefault(migratePlaintextSecrets({ ...defaults, ...settingsStore.store })) + ) return { ...persisted, llmApiKey: decryptSecret(secretStore.get('llmApiKey')), diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index 01bf951..6a11586 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -188,11 +188,17 @@ export interface ZhiziCloudLoginResult { export interface ZhiziCloudConnectionTestResult { ok: boolean message: string + state?: 'logged-out' | 'ready' | 'token-expired' | 'network-error' | 'worker-unavailable' | 'entitlement-error' | 'error' candidateCount?: number topMove?: string visits?: number winrate?: number scoreMean?: number + visitsPerSecond?: number + readyMillis?: number + analysisMillis?: number + sessionReused?: boolean + gpuType?: string tokenValid?: boolean isMembership?: boolean membershipExpiresAt?: string @@ -229,6 +235,7 @@ export interface SystemProfile { proxyApiKey: string proxyModels: string[] hasLlmApiKey: boolean + hasZhiziToken: boolean notes: string[] } diff --git a/src/main/services/analysis/runtimeIntegration.ts b/src/main/services/analysis/runtimeIntegration.ts index 093fe77..f3e03b4 100644 --- a/src/main/services/analysis/runtimeIntegration.ts +++ b/src/main/services/analysis/runtimeIntegration.ts @@ -303,7 +303,12 @@ export async function analyzePositionWithProgressRuntime(input: RuntimeAnalyzePo input.onProgress?.(attachRuntimeAnalysisEvidence({ analysis, profile, cache: progressCache, cacheKey }), isFinal) }, input.reportDuringSearchEvery ?? profile.reportDuringSearchEvery, - input.onSearchProgress + input.onSearchProgress, + { + runId: input.runId, + group: input.group, + replaceGroup: input.group === 'live' || !input.runId + } ) const enriched = attachRuntimeAnalysisEvidence({ analysis: final, profile, cache: lookup, cacheKey }) const written = writeAnalysisCache(keyInput, enriched, lookup.entry) diff --git a/src/main/services/analysis/scheduler.ts b/src/main/services/analysis/scheduler.ts index 2061231..5bafb1c 100644 --- a/src/main/services/analysis/scheduler.ts +++ b/src/main/services/analysis/scheduler.ts @@ -70,7 +70,20 @@ function priorityForGroup(group?: KataGoAnalysisGroup): ScheduledAnalysisPriorit } function updateRecent(snapshot: ScheduledAnalysisSnapshot): void { - recent.set(snapshot.id, { ...snapshot }) + recent.set(snapshot.id, { + id: snapshot.id, + runId: snapshot.runId, + group: snapshot.group, + priority: snapshot.priority, + description: snapshot.description, + status: snapshot.status, + queuedAt: snapshot.queuedAt, + startedAt: snapshot.startedAt, + endedAt: snapshot.endedAt, + queueWaitMs: snapshot.queueWaitMs, + runMs: snapshot.runMs, + error: snapshot.error + }) const ordered = Array.from(recent.values()).sort((a, b) => b.queuedAt.localeCompare(a.queuedAt)) for (const stale of ordered.slice(MAX_RECENT)) recent.delete(stale.id) } diff --git a/src/main/services/katago.ts b/src/main/services/katago.ts index e57334b..ae2a967 100644 --- a/src/main/services/katago.ts +++ b/src/main/services/katago.ts @@ -1047,7 +1047,12 @@ export async function analyzePositionWithProgress( visits: number visitsPerSecond: number isDuringSearch: boolean - }) => void + }) => void, + options: { + runId?: string + group?: KataGoAnalysisGroup + replaceGroup?: boolean + } = {} ): Promise { maxVisits = effectiveVisitsForSpeedMode(maxVisits, 'position') const indexedGame = findGame(gameId) @@ -1136,9 +1141,9 @@ export async function analyzePositionWithProgress( onProgress?.(partial, !latestBefore.isDuringSearch && !latestAfter.isDuringSearch && !latestActual?.isDuringSearch) } }, { - runId: `${gameId}-live-${moveNumber}`, - group: 'live', - replaceGroup: true, + runId: options.runId ?? `${gameId}-live-${moveNumber}`, + group: options.group ?? 'live', + replaceGroup: options.replaceGroup ?? true, onSearchProgress }) } catch (error) { diff --git a/src/main/services/systemProfile.ts b/src/main/services/systemProfile.ts index 00a5cd4..2c28abe 100644 --- a/src/main/services/systemProfile.ts +++ b/src/main/services/systemProfile.ts @@ -7,6 +7,7 @@ import type { AppSettings, SystemProfile } from '@main/lib/types' import { hydrateKataGoSettings, KATAGO_MODEL_PRESETS, resolveKataGoRuntime } from './katagoRuntime' import { ikatagoClientConfigured, shouldPreferIKataGoEngine } from './ikatagoClientEngine' import { shouldPreferZhiziGtpEngine, zhiziGtpConfigured } from './zhiziGtpEngine' +import { getZhiziPersistentSessionTelemetry } from './zhiziSocketSession' const execFileAsync = promisify(execFile) @@ -102,22 +103,32 @@ async function detectCliproxy(): Promise { const katago = resolveKataGoRuntime(settings) - const zhiziReady = settings ? shouldPreferZhiziGtpEngine(settings, katago.ready) && zhiziGtpConfigured(settings) : false + const zhiziSelected = settings ? shouldPreferZhiziGtpEngine(settings, katago.ready) && zhiziGtpConfigured(settings) : false + const zhiziConnected = zhiziSelected && getZhiziPersistentSessionTelemetry().ready const zhiziMissing = settings?.katagoEngineMode === 'zhizi' && !zhiziGtpConfigured(settings) const ikatagoReady = settings ? shouldPreferIKataGoEngine(settings, katago.ready) && ikatagoClientConfigured(settings) : false const proxy = await detectCliproxy() return { - katagoBin: zhiziReady ? 'zhizi-cloud-socket' : ikatagoReady && settings ? settings.ikatagoClientBin : katago.katagoBin, + katagoBin: zhiziSelected ? 'zhizi-cloud-socket' : ikatagoReady && settings ? settings.ikatagoClientBin : katago.katagoBin, katagoConfig: katago.katagoConfig, katagoModel: katago.katagoModel, - katagoReady: zhiziMissing ? false : zhiziReady || ikatagoReady || katago.ready, - katagoStatus: zhiziMissing ? 'Zhizi Cloud Login Required' : zhiziReady ? 'Zhizi Cloud Direct Ready' : ikatagoReady ? 'iKataGo Remote Ready' : katago.status, + katagoReady: zhiziMissing ? false : zhiziSelected || ikatagoReady || katago.ready, + katagoStatus: zhiziMissing + ? 'Zhizi Cloud Login Required' + : zhiziConnected + ? 'Zhizi Cloud Direct Ready' + : zhiziSelected + ? 'Zhizi Cloud Ready To Connect' + : ikatagoReady + ? 'iKataGo Remote Ready' + : katago.status, katagoModelPreset: katago.modelPreset.id, katagoModelPresets: KATAGO_MODEL_PRESETS, proxyBaseUrl: proxy.proxyBaseUrl, proxyApiKey: proxy.proxyApiKey, proxyModels: proxy.proxyModels, hasLlmApiKey: false, + hasZhiziToken: Boolean(settings?.zhiziToken.trim()), notes: [...katago.notes, ...proxy.notes], } } diff --git a/src/main/services/zhiziCloudAuth.ts b/src/main/services/zhiziCloudAuth.ts index a1e4075..21d5360 100644 --- a/src/main/services/zhiziCloudAuth.ts +++ b/src/main/services/zhiziCloudAuth.ts @@ -110,7 +110,7 @@ function maskZhiziUsername(value: unknown): string { return text.replace(/(\d{3})\d+(\d{2})/, '$1***$2') } -async function getZhiziJson(url: string, token: string, timeoutMs = 15_000): Promise<{ status: number; ok: boolean; json: unknown; rawText: string }> { +async function getZhiziJsonOnce(url: string, token: string, timeoutMs = 15_000): Promise<{ status: number; ok: boolean; json: unknown; rawText: string }> { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) try { @@ -140,11 +140,34 @@ async function getZhiziJson(url: string, token: string, timeoutMs = 15_000): Pro } } +async function getZhiziJson(url: string, token: string, timeoutMs = 15_000): Promise<{ status: number; ok: boolean; json: unknown; rawText: string }> { + let lastError: unknown + for (let attempt = 1; attempt <= 2; attempt += 1) { + try { + return await getZhiziJsonOnce(url, token, timeoutMs) + } catch (cause) { + lastError = cause + if (attempt >= 2) throw cause + await new Promise((resolve) => setTimeout(resolve, 650)) + } + } + throw lastError +} + function objectValue(value: unknown): Record { if (!value || typeof value !== 'object') return {} return value as Record } +function responsePayload(value: unknown): Record { + const root = objectValue(value) + for (const key of ['data', 'result', 'account', 'user']) { + const nested = objectValue(root[key]) + if (Object.keys(nested).length) return nested + } + return root +} + export async function getZhiziCloudAccountStatus(token: string): Promise { const trimmed = token.trim() if (!trimmed) { @@ -152,16 +175,23 @@ export async function getZhiziCloudAccountStatus(token: string): Promise['code']): ZhiziCloudConnectionTestResult['state'] { + if (code === 'auth') return 'token-expired' + if (code === 'network' || code === 'timeout') return 'network-error' + if (code === 'capacity') return 'worker-unavailable' + if (code === 'entitlement') return 'entitlement-error' + return 'error' +} + +export async function probeZhiziCloudConnection( + settings: AppSettings +): Promise { + if (!settings.zhiziToken.trim()) { + return { + ok: false, + state: 'logged-out', + message: '智子云未登录:请先用账号密码或短信验证码登录。' + } + } + + let accountStatus: Awaited> + let accountStatusWarning = '' + try { + accountStatus = await getZhiziCloudAccountStatus(settings.zhiziToken) + } catch { + accountStatus = { + tokenValid: true, + isMembership: false, + hasConnectAccount: false + } + accountStatusWarning = '账号资料暂时无法读取,已直接检测远程引擎。' + } + if (!accountStatus.tokenValid) { + return { + ok: false, + state: 'token-expired', + message: '智子云登录已失效,请重新登录。', + ...accountStatus + } + } + + const before = getZhiziPersistentSessionTelemetry() + const startedAt = Date.now() + let latestVisits = 0 + let latestSpeed = 0 + try { + const results = await queryZhiziGtpAnalysisBatch({ + settings: { + ...settings, + katagoEngineMode: 'zhizi', + zhiziClientBin: '' + }, + runId: `zhizi-smoke-${Date.now()}`, + group: 'quick', + timeoutMs: 120_000, + queries: [ + { + id: 'zhizi-smoke', + boardXSize: 19, + boardYSize: 19, + komi: 7.5, + initialPlayer: 'B', + moves: [ + ['B', 'D4'], + ['W', 'Q16'], + ['B', 'Q4'] + ], + maxVisits: 64 + } + ], + onSearchProgress: (progress) => { + latestVisits = Math.max(latestVisits, progress.visits) + if (progress.visitsPerSecond > 0) latestSpeed = progress.visitsPerSecond + } + }) + const result = results.get('zhizi-smoke') + const best = result?.moveInfos?.[0] + if (!best) { + return { + ok: false, + state: 'error', + message: '智子云已连接,但没有返回候选点。请稍后重试。', + ...accountStatus + } + } + const after = getZhiziPersistentSessionTelemetry() + return { + ok: true, + state: 'ready', + message: [ + before.ready + ? '智子云连接正常,已复用远程引擎并收到分析结果。' + : '智子云连接成功,远程 KataGo 已返回分析结果。', + accountStatusWarning + ].filter(Boolean).join(' '), + candidateCount: result?.moveInfos?.length ?? 0, + topMove: typeof best.move === 'string' ? best.move : undefined, + visits: typeof best.visits === 'number' ? best.visits : latestVisits || undefined, + winrate: typeof best.winrate === 'number' ? best.winrate : undefined, + scoreMean: typeof best.scoreMean === 'number' + ? best.scoreMean + : typeof best.scoreLead === 'number' + ? best.scoreLead + : undefined, + visitsPerSecond: latestSpeed || undefined, + readyMillis: before.ready ? 0 : after.lastReadyMillis, + analysisMillis: Date.now() - startedAt, + sessionReused: before.ready, + gpuType: settings.zhiziGpuType || 'vip-share', + ...accountStatus + } + } catch (cause) { + const error = classifyZhiziRemoteError(cause, settings.zhiziGpuType || 'vip-share') + return { + ok: false, + state: resultState(error.code), + message: error.message, + gpuType: settings.zhiziGpuType || 'vip-share', + ...accountStatus + } + } +} diff --git a/src/main/services/zhiziGtpEngine.ts b/src/main/services/zhiziGtpEngine.ts index d0e1cb3..f5666fe 100644 --- a/src/main/services/zhiziGtpEngine.ts +++ b/src/main/services/zhiziGtpEngine.ts @@ -1,19 +1,25 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { io, type Socket } from 'socket.io-client' import type { AppSettings, GameMove, KataGoAnalysisGroup } from '@main/lib/types' - -export type ZhiziGtpAnalysisResponse = Record & { - id?: string - error?: string - isDuringSearch?: boolean - rootInfo?: { - currentPlayer?: GameMove['color'] - winrate?: number - scoreLead?: number - scoreMean?: number - } - moveInfos?: Array> -} +import { + classifyZhiziRemoteError, + getZhiziPersistentSession, + redactZhiziText, + type ZhiziPersistentSession +} from './zhiziSocketSession' +import { + buildZhiziRemoteArgs, + parseKataAnalyzeInfo, + zhiziAnalysisReachedVisits, + zhiziResponseVisitTotal, + type ZhiziGtpAnalysisResponse +} from './zhiziGtpProtocol' + +export { + buildZhiziRemoteArgs, + parseKataAnalyzeInfo, + zhiziAnalysisReachedVisits +} from './zhiziGtpProtocol' +export type { ZhiziGtpAnalysisResponse } from './zhiziGtpProtocol' export interface ZhiziGtpAnalysisBatchRequest { settings: AppSettings @@ -54,28 +60,7 @@ type GtpQuery = { } const activeZhiziProcesses = new Map() -const ZHIZI_SOCKET_TOKEN_URL = 'https://www.zhizigo.com/api/cluster/account/fetch-socketio-token' -const GTP_VALUE_KEYS = new Set([ - 'move', - 'visits', - 'edgeVisits', - 'utility', - 'winrate', - 'scoreMean', - 'scoreStdev', - 'scoreLead', - 'scoreSelfplay', - 'prior', - 'lcb', - 'order', - 'pv', - 'pvVisits', - 'isSymmetryOf', - 'ownership', - 'ownershipStdev', - 'rootInfo' -]) - +const ZHIZI_COLD_START_GTP_TIMEOUT_MS = 60_000 function splitCommandLine(input: string): string[] { const args: string[] = [] let current = '' @@ -114,36 +99,8 @@ function splitCommandLine(input: string): string[] { return args } -function shellQuote(value: string): string { - if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) return value - return `'${value.replace(/'/g, `'\\''`)}'` -} - -function directSocketArgs(settings: AppSettings): string { - const gpuType = settings.zhiziGpuType?.trim() || 'vip-share' - const args = [ - '--platform', 'all', - '--engine-type', 'go', - '--gpu-type', gpuType, - '--kata-name', 'katago-TENSORRT', - '--kata-weight', '28bnbt' - ] - args.push(...splitCommandLine(settings.zhiziExtraArgs).filter((arg) => arg !== '--token')) - return args.map(shellQuote).join(' ') -} - -function chunkToText(chunk: unknown): string { - if (typeof chunk === 'string') return chunk - if (Buffer.isBuffer(chunk)) return chunk.toString('utf8') - if (chunk instanceof ArrayBuffer) return Buffer.from(chunk).toString('utf8') - if (ArrayBuffer.isView(chunk)) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString('utf8') - return String(chunk ?? '') -} - function redactZhiziLog(text: string): string { - return text - .replace(/zz-\d{4,}/g, 'zz-[redacted]') - .replace(/(token=|--token\s+|zz-socketio-token[=:])\S+/gi, '$1[redacted]') + return redactZhiziText(text) } function cleanVertex(move: string): string { @@ -174,13 +131,6 @@ function analysisMillis(maxVisits: number | undefined): number { return 5600 } -function responseVisitTotal(response: ZhiziGtpAnalysisResponse): number { - return (response.moveInfos ?? []).reduce((sum, move) => { - const visits = Number(move.visits ?? 0) - return sum + (Number.isFinite(visits) && visits > 0 ? visits : 0) - }, 0) -} - function emitAnalyzeProgress( request: ZhiziGtpAnalysisBatchRequest, queryId: string, @@ -192,7 +142,7 @@ function emitAnalyzeProgress( if (!/\binfo\s+/.test(text)) return const response = parseKataAnalyzeInfo(text, player, queryId) if (response.error || !response.moveInfos?.length) return - const visits = responseVisitTotal(response) + const visits = zhiziResponseVisitTotal(response) if (visits <= state.lastVisits) return const now = Date.now() const elapsedSeconds = Math.max(0.1, (now - state.lastSampleAt) / 1000) @@ -209,17 +159,6 @@ function emitAnalyzeProgress( }) } -function normalizeRate(value: number): number { - if (!Number.isFinite(value)) return 0 - if (Math.abs(value) > 1.00001) return value / 10000 - return value -} - -function numericToken(tokens: string[], index: number): number | undefined { - const value = Number(tokens[index]) - return Number.isFinite(value) ? value : undefined -} - function hasGtpTerminator(text: string): boolean { return /\r?\n\r?\n/.test(text) } @@ -232,113 +171,6 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -function isSocketConnectFailure(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /Socket (未连接|连接失败|已断开)|websocket error|xhr poll error|transport error|timeout|KataGo 启动超时/i.test(message) -} - -function isZhiziNoCreditError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /not_enough_credit|余额不足|not enough credit/i.test(message) -} - -function zhiziNoCreditError(): Error { - return new Error('智子云远程 worker 返回 not_enough_credit:主账号 token 可用,但当前连接账号/GPU 算力额度没有被远程 worker 接受。请在智子官方 App 确认远程算力权益已开通并同步到连接账号。') -} - -function parseKeyValues(text: string): Record { - const tokens = text.split(/\s+/).filter(Boolean) - const values: Record = {} - for (let i = 0; i < tokens.length - 1; i += 1) { - const key = tokens[i] - const value = numericToken(tokens, i + 1) - if (value === undefined) continue - if (key === 'winrate' || key === 'prior' || key === 'lcb') values[key] = normalizeRate(value) - else values[key] = value - i += 1 - } - return values -} - -export function parseKataAnalyzeInfo(text: string, player: GameMove['color'], id: string): ZhiziGtpAnalysisResponse { - const compact = text - .replace(/\r/g, '\n') - .replace(/^=.*$/gm, '') - .replace(/\s+/g, ' ') - .trim() - const rootInfoText = compact.match(/\brootInfo\s+(.+?)(?:\s+ownership\s+|$)/)?.[1] ?? '' - const rootInfo = parseKeyValues(rootInfoText) - const segments = compact.split(/\binfo\s+/).map((segment) => segment.trim()).filter(Boolean) - const moveInfos: Array> = [] - - for (const segment of segments) { - const moveSegment = segment.split(/\brootInfo\b/)[0] - const tokens = moveSegment.split(/\s+/).filter(Boolean) - const info: Record = {} - for (let i = 0; i < tokens.length; i += 1) { - const key = tokens[i] - if (!GTP_VALUE_KEYS.has(key)) continue - if (key === 'rootInfo' || key === 'ownership' || key === 'ownershipStdev') break - if (key === 'pv') { - const pv: string[] = [] - for (let j = i + 1; j < tokens.length; j += 1) { - if (GTP_VALUE_KEYS.has(tokens[j])) break - pv.push(tokens[j]) - i = j - } - info.pv = pv.slice(0, 24) - continue - } - if (key === 'pvVisits') { - const pvVisits: number[] = [] - for (let j = i + 1; j < tokens.length; j += 1) { - if (GTP_VALUE_KEYS.has(tokens[j])) break - const value = Number(tokens[j]) - if (Number.isFinite(value)) pvVisits.push(value) - i = j - } - info.pvVisits = pvVisits.slice(0, 24) - continue - } - if (key === 'move' || key === 'isSymmetryOf') { - info[key] = tokens[i + 1] ?? '' - i += 1 - continue - } - const value = numericToken(tokens, i + 1) - if (value !== undefined) { - if (key === 'winrate' || key === 'prior' || key === 'lcb') info[key] = normalizeRate(value) - else info[key] = value - i += 1 - } - } - if (typeof info.move === 'string' && info.move) { - moveInfos.push(info) - } - } - - moveInfos.sort((a, b) => Number(a.order ?? 9999) - Number(b.order ?? 9999)) - const best = moveInfos[0] - if (!best) { - return { - id, - error: '智子云 KataGo GTP 没有返回候选点。', - rootInfo: { currentPlayer: player, winrate: 0.5, scoreLead: 0 }, - moveInfos: [] - } - } - return { - id, - rootInfo: { - currentPlayer: player, - winrate: Number(rootInfo.winrate ?? best.winrate ?? 0.5), - scoreLead: Number(rootInfo.scoreLead ?? rootInfo.scoreMean ?? best.scoreLead ?? best.scoreMean ?? 0), - scoreMean: Number(rootInfo.scoreMean ?? rootInfo.scoreLead ?? best.scoreMean ?? best.scoreLead ?? 0) - }, - moveInfos - } -} - export function zhiziGtpConfigured(settings: AppSettings): boolean { if (settings.zhiziToken.trim()) { return true @@ -396,136 +228,84 @@ function asGtpQuery(query: Record & { id?: string }): GtpQuery } } -async function fetchZhiziSocketToken(token: string, args: string): Promise<{ socketIOURL: string; token: string }> { - let response: Response - try { - response = await fetch(ZHIZI_SOCKET_TOKEN_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ args }) - }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`智子云连接令牌获取失败:网络连接中断,请稍后重试。${message ? ` (${redactZhiziLog(message)})` : ''}`) - } - const rawText = await response.text() - let json: Record = {} - try { - json = rawText ? JSON.parse(rawText) as Record : {} - } catch { - json = {} - } - if (!response.ok) { - const key = String(json.key ?? json.error ?? json.message ?? '').trim() - if (response.status === 401 || /invalid|token|auth/i.test(key)) { - throw new Error('智子云 token 已失效,请重新登录。') - } - if (/not_enough_credit|credit/i.test(key)) { - throw zhiziNoCreditError() - } - throw new Error(`智子云连接令牌获取失败:HTTP ${response.status}${key ? ` · ${key}` : ''}`) - } - const socketIOURL = typeof json.socketIOURL === 'string' ? json.socketIOURL.trim() : '' - const socketToken = typeof json.token === 'string' ? json.token.trim() : '' - if (!socketIOURL || !socketToken) { - throw new Error('智子云连接令牌响应不完整,请稍后重试。') - } - return { socketIOURL, token: socketToken } -} - async function queryZhiziSocketGtpAnalysisBatch(request: ZhiziGtpAnalysisBatchRequest): Promise> { + if (!request.queries.length) return new Map() const token = request.settings.zhiziToken.trim() if (!token) { throw new Error('智子云未登录:请先在 GoAgent 中输入账号密码登录。') } const runId = request.runId || `zhizi-direct-${Date.now()}-${Math.random().toString(36).slice(2)}` - let socket: Socket | null = null + const controller = new AbortController() const activeEntry: ActiveZhiziProcess = { group: request.group, cancelled: false, - stop: () => socket?.disconnect() + stop: () => controller.abort() } activeZhiziProcesses.set(runId, activeEntry) - let stdout = '' - let stderr = '' - let socketError: Error | null = null - let closing = false - let remoteReady = false - let socketBoardKey = '' - function cleanup(): void { const current = activeZhiziProcesses.get(runId) if (current === activeEntry) activeZhiziProcesses.delete(runId) } - function waitUntil(predicate: () => boolean, timeoutMs: number, description: string): Promise { - const started = Date.now() - return new Promise((resolve, reject) => { - const timer = setInterval(() => { - if (activeEntry.cancelled) { - clearInterval(timer) - reject(new Error('智子云分析已取消')) - return - } - if (socketError) { - clearInterval(timer) - reject(socketError) - return - } - if (predicate()) { - clearInterval(timer) - resolve() - return - } - if (Date.now() - started > timeoutMs) { - clearInterval(timer) - reject(new Error(`${description}超时。\n${redactZhiziLog(stderr.trim().slice(-1200))}`)) - } - }, 50) - }) - } - - function sendRaw(text: string): void { - if (socketError) { - throw socketError - } - if (!socket?.connected) { - const tail = redactZhiziLog(stderr.trim().slice(-1200)) - throw new Error(`智子云 Socket 未连接。${tail ? `\n${tail}` : ''}`) - } - socket.emit('stdin', text) - } - - async function sendCommands(commandLines: string[], description: string): Promise { + const args = buildZhiziRemoteArgs(request.settings) + const session = getZhiziPersistentSession({ + accountToken: token, + args, + gpuType: request.settings.zhiziGpuType?.trim() || 'vip-share' + }) + const overallDeadline = Date.now() + ( + request.timeoutMs ?? Math.max(240_000, request.queries.length * 9_000) + ) + + async function sendCommands( + channel: ZhiziPersistentSession, + commandLines: string[], + description: string + ): Promise { if (!commandLines.length) return '' - const start = stdout.length - sendRaw(`${commandLines.join('\n')}\n`) - const timeoutMs = Math.max(10_000, commandLines.length * 600) - await waitUntil(() => countGtpResponses(stdout.slice(start)) >= commandLines.length, timeoutMs, description) - const text = stdout.slice(start) + const start = channel.output().stdout.length + const disconnectVersion = channel.captureDisconnectVersion() + channel.send(`${commandLines.join('\n')}\n`) + // The Socket.IO runner can emit "ready" before a cold 28B KataGo model has + // finished loading. Keep commands buffered and allow the same startup + // window used by the mature LizzieYZY remote flow. + const timeoutMs = Math.max( + ZHIZI_COLD_START_GTP_TIMEOUT_MS, + commandLines.length * 1_200 + ) + await channel.waitUntil( + () => countGtpResponses(channel.output().stdout.slice(start)) >= commandLines.length, + timeoutMs, + description, + controller.signal, + disconnectVersion + ) + const text = channel.output().stdout.slice(start) if (/(?:^|\n)\?/.test(text.trim())) { throw new Error(`${description}失败。\n${redactZhiziLog(text.trim().slice(0, 1200))}`) } return text } - async function analyzeQuery(query: GtpQuery): Promise { + async function analyzeQuery( + channel: ZhiziPersistentSession, + query: GtpQuery, + boardState: { key: string } + ): Promise { + channel.clearOutput() const boardXSize = Math.max(2, Math.round(query.boardXSize || 19)) const boardYSize = Math.max(2, Math.round(query.boardYSize || boardXSize)) const boardKey = `${boardXSize}x${boardYSize}` const setupCommands: string[] = [] - if (socketBoardKey !== boardKey) { + if (boardState.key !== boardKey) { setupCommands.push( boardXSize === boardYSize ? `boardsize ${boardXSize}` : `rectangular_boardsize ${boardXSize} ${boardYSize}`, 'kata-set-rules chinese', 'time_settings 0 5 1' ) - socketBoardKey = boardKey + boardState.key = boardKey } setupCommands.push( `komi ${Number.isFinite(query.komi) ? query.komi : 7.5}`, @@ -537,148 +317,107 @@ async function queryZhiziSocketGtpAnalysisBatch(request: ZhiziGtpAnalysisBatchRe for (const [color, point] of query.moves) { setupCommands.push(`play ${color} ${cleanVertex(point)}`) } - await sendCommands(setupCommands, '智子云 GTP 初始化局面') + await sendCommands(channel, setupCommands, '智子云 GTP 初始化局面') const player = sideToMove(query) const allow = (query.allowMoves ?? []) .filter((entry) => entry.player && entry.moves?.length) .map((entry) => `allow ${entry.player} ${entry.moves.map(cleanVertex).join(',')} ${Math.max(1, Math.round(entry.untilDepth || 1))}`) .join(' ') - const start = stdout.length - const commandLine = `kata-analyze ${player} 8 rootInfo true maxmoves 20${allow ? ` ${allow}` : ''}` - sendRaw(`${commandLine}\n`) + const start = channel.output().stdout.length + const disconnectVersion = channel.captureDisconnectVersion() + const commandLine = `kata-analyze ${player} 25 rootInfo true maxmoves 20${allow ? ` ${allow}` : ''}` + channel.send(`${commandLine}\n`) const searchStartedAt = Date.now() const progressState = { lastVisits: 0, lastSampleAt: searchStartedAt } - const durationMs = analysisMillis(query.maxVisits) - while (Date.now() - searchStartedAt < durationMs) { - await delay(300) + const targetVisits = Math.max(1, Math.round(query.maxVisits ?? 120)) + const queryTimeoutMs = Math.max(15_000, Math.min(120_000, targetVisits * 220)) + let latestResponse: ZhiziGtpAnalysisResponse | null = null + + while (Date.now() - searchStartedAt < queryTimeoutMs && Date.now() < overallDeadline) { + await delay(100) if (activeEntry.cancelled) throw new Error('智子云分析已取消') - emitAnalyzeProgress(request, query.id, player, stdout.slice(start), progressState, true) + const output = channel.output().stdout.slice(start) + if (!/\binfo\s+/.test(output)) continue + const response = parseKataAnalyzeInfo(output, player, query.id) + if (response.error || !response.moveInfos?.length) continue + latestResponse = response + emitAnalyzeProgress(request, query.id, player, output, progressState, true) + if (zhiziAnalysisReachedVisits(response, targetVisits)) break } - sendRaw('\n') - await waitUntil(() => hasGtpTerminator(stdout.slice(start)), 15_000, '智子云 kata-analyze') - const response = parseKataAnalyzeInfo(stdout.slice(start), player, query.id) - emitAnalyzeProgress(request, query.id, player, stdout.slice(start), progressState, false) - request.onResponse?.(response) - return response - } - async function connectRemoteSocket(): Promise { - const maxConnectAttempts = 3 - for (let attempt = 1; attempt <= maxConnectAttempts; attempt += 1) { - if (activeEntry.cancelled) throw new Error('智子云分析已取消') - stdout = '' - stderr = '' - socketError = null - remoteReady = false - socketBoardKey = '' - if (socket) { - socket.removeAllListeners() - socket.disconnect() - socket = null - } - const socketInfo = await fetchZhiziSocketToken(token, directSocketArgs(request.settings)) - socket = io(socketInfo.socketIOURL, { - path: '/socket.io.v4', - query: { 'zz-socketio-token': socketInfo.token }, - transports: ['websocket'], - timeout: 20_000, - reconnection: false, - forceNew: true - }) - socket.on('stdout', (chunk) => { - stdout += chunkToText(chunk) - }) - socket.on('stderr', (chunk) => { - stderr = (stderr + chunkToText(chunk)).slice(-40_000) - }) - socket.on('connect_error', (error) => { - socketError = new Error(`智子云 Socket 连接失败:${error.message}`) - }) - socket.on('ready', () => { - remoteReady = true - }) - socket.on('disconnect', (reason) => { - if (!closing && !activeEntry.cancelled) { - const tail = redactZhiziLog(stderr.trim().slice(-1200)) - socketError = /not_enough_credit|not enough credit/i.test(tail) - ? zhiziNoCreditError() - : new Error(`智子云 Socket 已断开:${reason}\n${tail}`) - } - }) - try { - await waitUntil(() => Boolean(socket?.connected && (remoteReady || /GTP ready|beginning main protocol loop/i.test(stderr + stdout))), 90_000, '智子云 KataGo 启动') - return - } catch (error) { - if (socket) { - socket.removeAllListeners() - socket.disconnect() - socket = null - } - if (isZhiziNoCreditError(error)) { - throw zhiziNoCreditError() - } - if (attempt >= maxConnectAttempts || !isSocketConnectFailure(error)) { - throw error - } - console.warn(`Zhizi direct Socket.IO connect attempt ${attempt} failed; retrying.`, error) - await delay(800 * attempt) - } + if (!latestResponse?.moveInfos?.length) { + throw new Error('智子云已连接,但在限定时间内没有返回候选点。') } - throw new Error('智子云 Socket 连接失败,请稍后重试。') + channel.send('stop\n') + await channel.waitUntil( + () => hasGtpTerminator(channel.output().stdout.slice(start)), + 12_000, + '智子云 kata-analyze', + controller.signal, + disconnectVersion + ) + const finalOutput = channel.output().stdout.slice(start) + const parsedFinal = parseKataAnalyzeInfo(finalOutput, player, query.id) + const response = parsedFinal.moveInfos?.length ? parsedFinal : latestResponse + emitAnalyzeProgress(request, query.id, player, finalOutput, progressState, false) + request.onResponse?.(response) + return response } try { - await connectRemoteSocket() - const results = new Map() - for (const rawQuery of request.queries) { - const query = asGtpQuery(rawQuery) - let lastError: unknown - for (let attempt = 1; attempt <= 3; attempt += 1) { - try { - if (!(socket as Socket | null)?.connected || socketError) { - await connectRemoteSocket() + return await session.runExclusive(controller.signal, async (channel) => { + const results = new Map() + const boardState = { key: '' } + let firstCompletedAt = 0 + for (const rawQuery of request.queries) { + const query = asGtpQuery(rawQuery) + let lastError: ReturnType | null = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + if (attempt > 1) { + await channel.restart(controller.signal) + boardState.key = '' + } + const response = await analyzeQuery(channel, query, boardState) + if (response.id) results.set(response.id, response) + if (!firstCompletedAt) firstCompletedAt = Date.now() + lastError = null + break + } catch (cause) { + lastError = classifyZhiziRemoteError( + cause, + request.settings.zhiziGpuType?.trim() || 'vip-share' + ) + if ( + !lastError.retryable || + lastError.code === 'cancelled' || + activeEntry.cancelled || + attempt >= 3 + ) { + throw lastError + } + await delay(attempt * 500) } - const response = await analyzeQuery(query) - if (response.id) results.set(response.id, response) - lastError = undefined + } + if (lastError) throw lastError + if ( + request.resolvePartialAfterMs && + results.size < request.queries.length && + firstCompletedAt > 0 && + Date.now() - firstCompletedAt >= request.resolvePartialAfterMs + ) { break - } catch (error) { - lastError = error - if (isZhiziNoCreditError(error) || activeEntry.cancelled) { - throw error - } - if (!isSocketConnectFailure(error) || attempt >= 3) { - throw error - } - const currentSocket = socket as Socket | null - if (currentSocket) { - currentSocket.removeAllListeners() - currentSocket.disconnect() - socket = null - } - socketError = null - remoteReady = false - socketBoardKey = '' - console.warn(`Zhizi direct Socket.IO query ${query.id} failed; reconnecting (${attempt}/3).`, error) - await delay(500 * attempt) } } - if (lastError) throw lastError - } - closing = true - const currentSocket = socket as Socket | null - if (currentSocket?.connected) { - currentSocket.emit('stdin', 'quit\n') - } - currentSocket?.disconnect() - cleanup() - return results - } catch (error) { - closing = true - activeEntry.cancelled = true - ;(socket as Socket | null)?.disconnect() + return results + }) + } catch (cause) { + throw classifyZhiziRemoteError( + cause, + request.settings.zhiziGpuType?.trim() || 'vip-share' + ) + } finally { cleanup() - throw error } } diff --git a/src/main/services/zhiziGtpProtocol.ts b/src/main/services/zhiziGtpProtocol.ts new file mode 100644 index 0000000..0e53187 --- /dev/null +++ b/src/main/services/zhiziGtpProtocol.ts @@ -0,0 +1,245 @@ +import type { AppSettings, GameMove } from '@main/lib/types' + +export type ZhiziGtpAnalysisResponse = Record & { + id?: string + error?: string + isDuringSearch?: boolean + rootInfo?: { + currentPlayer?: GameMove['color'] + winrate?: number + scoreLead?: number + scoreMean?: number + } + moveInfos?: Array> +} + +const GTP_VALUE_KEYS = new Set([ + 'move', + 'visits', + 'edgeVisits', + 'utility', + 'winrate', + 'scoreMean', + 'scoreStdev', + 'scoreLead', + 'scoreSelfplay', + 'prior', + 'lcb', + 'order', + 'pv', + 'pvVisits', + 'isSymmetryOf', + 'ownership', + 'ownershipStdev', + 'rootInfo' +]) + +const MANAGED_REMOTE_OPTIONS = new Set([ + '--token', + '--platform', + '--engine-type', + '--gpu-type', + '--kata-name', + '--kata-weight' +]) + +function splitCommandLine(input: string): string[] { + const args: string[] = [] + let current = '' + let quote: '"' | "'" | '' = '' + let escaping = false + for (const char of input) { + if (escaping) { + current += char + escaping = false + continue + } + if (char === '\\') { + escaping = true + continue + } + if (quote) { + if (char === quote) quote = '' + else current += char + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (/\s/.test(char)) { + if (current) { + args.push(current) + current = '' + } + continue + } + current += char + } + if (escaping) current += '\\' + if (current) args.push(current) + return args +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) return value + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function sanitizedRemoteExtraArgs(input: string): string[] { + const source = splitCommandLine(input) + const sanitized: string[] = [] + for (let index = 0; index < source.length; index += 1) { + const argument = source[index] + const option = argument.split('=', 1)[0] + if (MANAGED_REMOTE_OPTIONS.has(option)) { + if (!argument.includes('=') && index + 1 < source.length) index += 1 + continue + } + sanitized.push(argument) + } + return sanitized +} + +export function buildZhiziRemoteArgs( + settings: Pick +): string { + const gpuType = settings.zhiziGpuType?.trim() || 'vip-share' + const args = [ + '--platform', 'all', + '--engine-type', 'go', + '--gpu-type', gpuType, + '--kata-name', 'katago-TENSORRT', + '--kata-weight', '28bnbt' + ] + args.push(...sanitizedRemoteExtraArgs(settings.zhiziExtraArgs)) + return args.map(shellQuote).join(' ') +} + +function normalizeRate(value: number): number { + if (!Number.isFinite(value)) return 0 + if (Math.abs(value) > 1.00001) return value / 10000 + return value +} + +function numericToken(tokens: string[], index: number): number | undefined { + const value = Number(tokens[index]) + return Number.isFinite(value) ? value : undefined +} + +function parseKeyValues(text: string): Record { + const tokens = text.split(/\s+/).filter(Boolean) + const values: Record = {} + for (let index = 0; index < tokens.length - 1; index += 1) { + const key = tokens[index] + const value = numericToken(tokens, index + 1) + if (value === undefined) continue + values[key] = key === 'winrate' || key === 'prior' || key === 'lcb' + ? normalizeRate(value) + : value + index += 1 + } + return values +} + +export function parseKataAnalyzeInfo( + text: string, + player: GameMove['color'], + id: string +): ZhiziGtpAnalysisResponse { + const completeLines = text + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => /\binfo\s+/.test(line)) + const latestFrame = completeLines[completeLines.length - 1] ?? text + const compact = latestFrame + .replace(/\r/g, '\n') + .replace(/^=.*$/gm, '') + .replace(/\s+/g, ' ') + .trim() + const rootInfoText = compact.match(/\brootInfo\s+(.+?)(?:\s+ownership\s+|$)/)?.[1] ?? '' + const rootInfo = parseKeyValues(rootInfoText) + const segments = compact.split(/\binfo\s+/).map((segment) => segment.trim()).filter(Boolean) + const moveInfos: Array> = [] + + for (const segment of segments) { + const moveSegment = segment.split(/\brootInfo\b/)[0] + const tokens = moveSegment.split(/\s+/).filter(Boolean) + const info: Record = {} + for (let index = 0; index < tokens.length; index += 1) { + const key = tokens[index] + if (!GTP_VALUE_KEYS.has(key)) continue + if (key === 'rootInfo' || key === 'ownership' || key === 'ownershipStdev') break + if (key === 'pv') { + const pv: string[] = [] + for (let cursor = index + 1; cursor < tokens.length; cursor += 1) { + if (GTP_VALUE_KEYS.has(tokens[cursor])) break + pv.push(tokens[cursor]) + index = cursor + } + info.pv = pv.slice(0, 24) + continue + } + if (key === 'pvVisits') { + const pvVisits: number[] = [] + for (let cursor = index + 1; cursor < tokens.length; cursor += 1) { + if (GTP_VALUE_KEYS.has(tokens[cursor])) break + const value = Number(tokens[cursor]) + if (Number.isFinite(value)) pvVisits.push(value) + index = cursor + } + info.pvVisits = pvVisits.slice(0, 24) + continue + } + if (key === 'move' || key === 'isSymmetryOf') { + info[key] = tokens[index + 1] ?? '' + index += 1 + continue + } + const value = numericToken(tokens, index + 1) + if (value !== undefined) { + info[key] = key === 'winrate' || key === 'prior' || key === 'lcb' + ? normalizeRate(value) + : value + index += 1 + } + } + if (typeof info.move === 'string' && info.move) moveInfos.push(info) + } + + moveInfos.sort((left, right) => Number(left.order ?? 9999) - Number(right.order ?? 9999)) + const best = moveInfos[0] + if (!best) { + return { + id, + error: '智子云 KataGo GTP 没有返回候选点。', + rootInfo: { currentPlayer: player, winrate: 0.5, scoreLead: 0 }, + moveInfos: [] + } + } + return { + id, + rootInfo: { + currentPlayer: player, + winrate: Number(rootInfo.winrate ?? best.winrate ?? 0.5), + scoreLead: Number(rootInfo.scoreLead ?? rootInfo.scoreMean ?? best.scoreLead ?? best.scoreMean ?? 0), + scoreMean: Number(rootInfo.scoreMean ?? rootInfo.scoreLead ?? best.scoreMean ?? best.scoreLead ?? 0) + }, + moveInfos + } +} + +export function zhiziResponseVisitTotal(response: ZhiziGtpAnalysisResponse): number { + return (response.moveInfos ?? []).reduce((sum, move) => { + const visits = Number(move.visits ?? 0) + return sum + (Number.isFinite(visits) && visits > 0 ? visits : 0) + }, 0) +} + +export function zhiziAnalysisReachedVisits( + response: ZhiziGtpAnalysisResponse, + targetVisits: number +): boolean { + return zhiziResponseVisitTotal(response) >= Math.max(1, Math.round(targetVisits)) +} diff --git a/src/main/services/zhiziSocketSession.ts b/src/main/services/zhiziSocketSession.ts new file mode 100644 index 0000000..14a194f --- /dev/null +++ b/src/main/services/zhiziSocketSession.ts @@ -0,0 +1,663 @@ +import { createHash } from 'node:crypto' +import { io } from 'socket.io-client' + +export type ZhiziRemoteErrorCode = + | 'cancelled' + | 'auth' + | 'entitlement' + | 'capacity' + | 'network' + | 'timeout' + | 'protocol' + | 'unknown' + +export type ZhiziSessionState = + | 'idle' + | 'connecting' + | 'ready' + | 'reconnecting' + | 'error' + | 'closed' + +export interface ZhiziSocketToken { + socketIOURL: string + token: string +} + +export interface ZhiziSocketLike { + connected: boolean + active?: boolean + on: (event: string, listener: (...args: unknown[]) => void) => unknown + emit: (event: string, ...args: unknown[]) => unknown + connect: () => unknown + disconnect: () => unknown + removeAllListeners: () => unknown +} + +export interface ZhiziSessionConfig { + accountToken: string + args: string + gpuType: string +} + +export interface ZhiziSessionTelemetry { + state: ZhiziSessionState + connected: boolean + ready: boolean + reusedConnections: number + connectionCount: number + lastReadyMillis: number + lastErrorCode?: ZhiziRemoteErrorCode + lastError?: string +} + +interface ZhiziSessionDependencies { + fetchSocketToken: (accountToken: string, args: string) => Promise + createSocket: (url: string, options: Record) => ZhiziSocketLike + sleep: (milliseconds: number) => Promise + now: () => number +} + +const READY_TIMEOUT_MS = 60_000 +const RECONNECT_WAIT_MS = 8_000 +const READY_FALLBACK_MS = 4_000 +const MAX_START_ATTEMPTS = 3 +const OUTPUT_LIMIT = 8 * 1024 * 1024 +const VIP_IDLE_TIMEOUT_MS = 5 * 60_000 +const ON_DEMAND_IDLE_TIMEOUT_MS = 90_000 + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export function decodeZhiziSocketPayload(payload: unknown): string { + if (typeof payload === 'string') return payload + if (Buffer.isBuffer(payload)) return payload.toString('utf8') + if (payload instanceof ArrayBuffer) return Buffer.from(payload).toString('utf8') + if (ArrayBuffer.isView(payload)) { + return Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength).toString('utf8') + } + if (Array.isArray(payload) && payload.every((item) => Number.isInteger(item))) { + return Buffer.from(payload).toString('utf8') + } + if (payload && typeof payload === 'object') { + const object = payload as { type?: unknown; data?: unknown } + if (object.type === 'Buffer' && Array.isArray(object.data)) { + return Buffer.from(object.data).toString('utf8') + } + } + return String(payload ?? '') +} + +export function redactZhiziText(text: string): string { + return text + .replace(/zz[-_][A-Za-z0-9_-]{4,}/g, 'zz-[redacted]') + .replace(/(Bearer\s+|token=|--token\s+|zz-socketio-token[=:])\S+/gi, '$1[redacted]') +} + +export class ZhiziRemoteError extends Error { + readonly code: ZhiziRemoteErrorCode + readonly retryable: boolean + + constructor(code: ZhiziRemoteErrorCode, message: string, retryable: boolean) { + super(message) + this.name = 'ZhiziRemoteError' + this.code = code + this.retryable = retryable + } +} + +export function classifyZhiziRemoteError( + cause: unknown, + gpuType = 'vip-share' +): ZhiziRemoteError { + if (cause instanceof ZhiziRemoteError) return cause + const raw = redactZhiziText(cause instanceof Error ? cause.message : String(cause)) + const text = raw.toLowerCase() + + if (/cancel|取消|aborted/.test(text)) { + return new ZhiziRemoteError('cancelled', '智子云分析已取消。', false) + } + if (/401|invalid.?token|token.?expired|token 已失效|unauthorized|登录失效/.test(text)) { + return new ZhiziRemoteError('auth', '智子云登录已失效,请重新登录。', false) + } + if (/not_enough_credit|not enough credit|余额不足/.test(text)) { + if (gpuType === 'vip-share') { + return new ZhiziRemoteError( + 'entitlement', + '智子云暂未给当前账号分配 VIP 共享引擎。GoAgent 已使用 VIP 专用参数,这不是独享余额判断;请重新登录后再试,仍失败时请在智子官方 App 确认 VIP 与连接账号已同步。', + true + ) + } + return new ZhiziRemoteError( + 'entitlement', + '智子云独享算力余额不足或当前档位不可用,请在智子官方 App 确认余额和算力档位。', + false + ) + } + if ( + /no worker|worker unavailable|worker busy|没有可用.*worker|暂无.*算力|无可用.*算力|capacity/.test(text) + ) { + return new ZhiziRemoteError( + 'capacity', + '智子云当前没有空闲算力,GoAgent 会自动重试;如果持续出现,请稍后再试或暂时切回本机分析。', + true + ) + } + if (/403|forbidden|permission|无权限|未开通|套餐/.test(text)) { + return new ZhiziRemoteError( + 'entitlement', + '当前智子账号没有所选远程算力的使用权限,请在智子官方 App 确认套餐和连接账号。', + false + ) + } + if (/timeout|timed out|超时/.test(text)) { + return new ZhiziRemoteError( + 'timeout', + '智子云连接超时,请检查网络后重试;当前本机分析设置不会被修改。', + true + ) + } + if ( + /websocket|xhr poll|transport|econn|socket|network|fetch failed|连接中断|连接失败|已断开/.test(text) + ) { + return new ZhiziRemoteError( + 'network', + '智子云网络连接中断,GoAgent 会自动重连;如果持续失败,请检查网络后重试。', + true + ) + } + if (/gtp|unknown command|protocol|候选点|返回内容/.test(text)) { + return new ZhiziRemoteError('protocol', raw || '智子云远程 KataGo 返回了无法识别的结果。', true) + } + return new ZhiziRemoteError('unknown', raw || '智子云远程分析失败。', true) +} + +export function zhiziStartupRetryDelayMs(completedAttempt: number): number { + if (completedAttempt <= 1) return 1_500 + if (completedAttempt === 2) return 4_000 + return 10_000 +} + +async function fetchSocketTokenOnce( + accountToken: string, + args: string +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 20_000) + try { + const response = await fetch( + 'https://www.zhizigo.com/api/cluster/account/fetch-socketio-token', + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accountToken}` + }, + body: JSON.stringify({ args }), + signal: controller.signal + } + ) + const rawText = await response.text() + let body: Record = {} + try { + body = rawText ? JSON.parse(rawText) as Record : {} + } catch { + if (!response.ok) { + throw classifyZhiziRemoteError(`HTTP ${response.status} ${rawText.slice(0, 240)}`) + } + const contentType = response.headers.get('content-type') ?? 'unknown' + const preview = redactZhiziText(rawText.replace(/\s+/g, ' ').trim().slice(0, 180)) + throw new ZhiziRemoteError( + 'protocol', + `智子云连接接口返回了无法识别的内容(HTTP ${response.status} · ${contentType})。${preview ? ` ${preview}` : ''}`, + true + ) + } + if (!response.ok) { + const detail = String(body.key ?? body.error ?? body.message ?? rawText).slice(0, 240) + throw classifyZhiziRemoteError(`HTTP ${response.status} ${detail}`) + } + const socketIOURL = typeof body.socketIOURL === 'string' ? body.socketIOURL.trim() : '' + const token = typeof body.token === 'string' ? body.token.trim() : '' + if (!socketIOURL || !token) { + throw new ZhiziRemoteError( + 'protocol', + '智子云连接信息不完整,请稍后重试。', + true + ) + } + return { socketIOURL, token } + } catch (cause) { + if (cause instanceof Error && cause.name === 'AbortError') { + throw new ZhiziRemoteError('timeout', '智子云连接令牌获取超时。', true) + } + throw classifyZhiziRemoteError(cause) + } finally { + clearTimeout(timer) + } +} + +export async function fetchZhiziSocketToken( + accountToken: string, + args: string +): Promise { + let lastError: ZhiziRemoteError | null = null + for (let attempt = 1; attempt <= 2; attempt += 1) { + try { + return await fetchSocketTokenOnce(accountToken, args) + } catch (cause) { + lastError = classifyZhiziRemoteError(cause) + if (!lastError.retryable || attempt >= 2) throw lastError + await sleep(650) + } + } + throw lastError ?? new ZhiziRemoteError('unknown', '智子云连接令牌获取失败。', true) +} + +const defaultDependencies: ZhiziSessionDependencies = { + fetchSocketToken: fetchZhiziSocketToken, + createSocket: (url, options) => io(url, options) as unknown as ZhiziSocketLike, + sleep, + now: Date.now +} + +export class ZhiziPersistentSession { + private readonly config: ZhiziSessionConfig + private readonly dependencies: ZhiziSessionDependencies + private socket: ZhiziSocketLike | null = null + private state: ZhiziSessionState = 'idle' + private ready = false + private everReady = false + private closed = false + private connectionPromise: Promise | null = null + private queueTail: Promise = Promise.resolve() + private stdout = '' + private stderr = '' + private lastError: ZhiziRemoteError | null = null + private connectionCount = 0 + private reusedConnections = 0 + private lastReadyMillis = 0 + private disconnectVersion = 0 + private connectErrorCount = 0 + private readyFallbackTimer: NodeJS.Timeout | null = null + private idleTimer: NodeJS.Timeout | null = null + + constructor( + config: ZhiziSessionConfig, + dependencies: Partial = {} + ) { + this.config = config + this.dependencies = { ...defaultDependencies, ...dependencies } + } + + get isClosed(): boolean { + return this.closed + } + + telemetry(): ZhiziSessionTelemetry { + return { + state: this.state, + connected: Boolean(this.socket?.connected), + ready: this.ready, + reusedConnections: this.reusedConnections, + connectionCount: this.connectionCount, + lastReadyMillis: this.lastReadyMillis, + lastErrorCode: this.lastError?.code, + lastError: this.lastError?.message + } + } + + clearOutput(): void { + this.stdout = '' + this.stderr = '' + } + + output(): { stdout: string; stderr: string } { + return { stdout: this.stdout, stderr: this.stderr } + } + + captureDisconnectVersion(): number { + return this.disconnectVersion + } + + send(command: string): void { + if (this.closed) { + throw new ZhiziRemoteError('network', '智子云连接已经关闭。', true) + } + if (!this.socket?.connected || !this.ready) { + throw this.lastError ?? new ZhiziRemoteError('network', '智子云连接尚未准备好。', true) + } + this.socket.emit('stdin', command) + } + + async waitUntil( + predicate: () => boolean, + timeoutMs: number, + description: string, + signal: AbortSignal, + expectedDisconnectVersion = this.disconnectVersion + ): Promise { + const started = this.dependencies.now() + while (true) { + if (signal.aborted) throw new ZhiziRemoteError('cancelled', '智子云分析已取消。', false) + if (this.closed) { + throw new ZhiziRemoteError('cancelled', '智子云连接已释放。', false) + } + if (this.disconnectVersion !== expectedDisconnectVersion) { + throw new ZhiziRemoteError('network', '智子云连接在分析过程中断开。', true) + } + if (predicate()) return + if (this.dependencies.now() - started >= timeoutMs) { + const stderrDetail = redactZhiziText(this.stderr.trim().slice(-800)) + const stdoutDetail = redactZhiziText(this.stdout.trim().slice(-800)) + const detail = [stderrDetail, stdoutDetail].filter(Boolean).join(' | ') + throw new ZhiziRemoteError( + 'timeout', + `${description}超时。${detail ? ` ${detail}` : ''}`, + true + ) + } + await this.dependencies.sleep(40) + } + } + + async runExclusive( + signal: AbortSignal, + operation: (session: ZhiziPersistentSession) => Promise + ): Promise { + const previous = this.queueTail + let release: () => void = () => undefined + this.queueTail = new Promise((resolve) => { + release = resolve + }) + await previous + this.clearIdleTimer() + try { + if (signal.aborted) throw new ZhiziRemoteError('cancelled', '智子云分析已取消。', false) + const wasReady = this.ready && Boolean(this.socket?.connected) + await this.ensureReady(signal) + if (wasReady) this.reusedConnections += 1 + const onAbort = (): void => this.interrupt() + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await operation(this) + } finally { + signal.removeEventListener('abort', onAbort) + } + } finally { + release() + this.scheduleIdleClose() + } + } + + async restart(signal: AbortSignal): Promise { + this.disposeSocket(false) + this.state = 'idle' + this.lastError = null + await this.ensureReady(signal) + } + + interrupt(): void { + if (this.socket?.connected && this.ready) { + this.socket.emit('stdin', 'stop\n') + } + } + + close(): void { + if (this.closed) return + this.closed = true + this.state = 'closed' + this.disconnectVersion += 1 + this.clearIdleTimer() + this.disposeSocket(true) + } + + private async ensureReady(signal: AbortSignal): Promise { + if (this.closed) throw new ZhiziRemoteError('network', '智子云连接已经关闭。', true) + if (this.ready && this.socket?.connected) return + + if (this.socket && this.everReady && !this.ready) { + try { + await this.waitForExistingReconnect(signal) + if (this.ready && this.socket?.connected) return + } catch (cause) { + if (classifyZhiziRemoteError(cause, this.config.gpuType).code === 'cancelled') throw cause + this.disposeSocket(false) + } + } + + if (!this.connectionPromise) { + this.connectionPromise = this.startWithRetries(signal).finally(() => { + this.connectionPromise = null + }) + } + await this.connectionPromise + } + + private async waitForExistingReconnect(signal: AbortSignal): Promise { + const started = this.dependencies.now() + while (this.dependencies.now() - started < RECONNECT_WAIT_MS) { + if (signal.aborted) throw new ZhiziRemoteError('cancelled', '智子云分析已取消。', false) + if (this.ready && this.socket?.connected) return + await this.dependencies.sleep(50) + } + throw new ZhiziRemoteError('network', '智子云自动重连未及时恢复。', true) + } + + private async startWithRetries(signal: AbortSignal): Promise { + let lastError: ZhiziRemoteError | null = null + for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt += 1) { + if (signal.aborted) throw new ZhiziRemoteError('cancelled', '智子云分析已取消。', false) + try { + await this.startAttempt(signal) + return + } catch (cause) { + lastError = classifyZhiziRemoteError(cause, this.config.gpuType) + this.lastError = lastError + this.state = 'error' + this.disposeSocket(false) + if (!lastError.retryable || attempt >= MAX_START_ATTEMPTS) throw lastError + await this.dependencies.sleep(zhiziStartupRetryDelayMs(attempt)) + } + } + throw lastError ?? new ZhiziRemoteError('unknown', '智子云连接失败。', true) + } + + private async startAttempt(signal: AbortSignal): Promise { + this.state = 'connecting' + this.ready = false + this.lastError = null + this.connectErrorCount = 0 + this.clearOutput() + + const socketToken = await this.dependencies.fetchSocketToken( + this.config.accountToken, + this.config.args + ) + if (signal.aborted) throw new ZhiziRemoteError('cancelled', '智子云分析已取消。', false) + + const socket = this.dependencies.createSocket(socketToken.socketIOURL, { + path: '/socket.io.v4', + query: { 'zz-socketio-token': socketToken.token }, + transports: ['websocket'], + timeout: 30_000, + reconnection: true, + reconnectionAttempts: Number.POSITIVE_INFINITY, + reconnectionDelay: 1_200, + reconnectionDelayMax: 8_000, + forceNew: true, + autoConnect: false + }) + this.socket = socket + this.connectionCount += 1 + this.attachSocketListeners(socket) + socket.connect() + + const started = this.dependencies.now() + while (this.dependencies.now() - started < READY_TIMEOUT_MS) { + if (signal.aborted) throw new ZhiziRemoteError('cancelled', '智子云分析已取消。', false) + if (this.ready && socket.connected) { + this.lastReadyMillis = this.dependencies.now() - started + return + } + const connectionError = this.lastError as ZhiziRemoteError | null + if (connectionError && (!connectionError.retryable || this.connectErrorCount >= 3)) { + throw connectionError + } + await this.dependencies.sleep(50) + } + throw this.lastError ?? new ZhiziRemoteError('timeout', '智子云远程引擎启动超时。', true) + } + + private attachSocketListeners(socket: ZhiziSocketLike): void { + const isCurrent = (): boolean => this.socket === socket && !this.closed + socket.on('connect', () => { + if (!isCurrent()) return + this.connectErrorCount = 0 + this.lastError = null + this.state = this.everReady ? 'reconnecting' : 'connecting' + if (this.everReady) this.scheduleReadyFallback(socket) + }) + socket.on('ready', () => { + if (!isCurrent()) return + this.markReady() + }) + socket.on('stdout', (payload) => { + if (!isCurrent()) return + this.stdout = this.appendLimited(this.stdout, decodeZhiziSocketPayload(payload)) + if (!this.ready && socket.connected && /GTP ready|beginning main protocol loop/i.test(this.stdout)) { + this.markReady() + } + }) + socket.on('stderr', (payload) => { + if (!isCurrent()) return + const text = decodeZhiziSocketPayload(payload) + this.stderr = this.appendLimited(this.stderr, text) + if (!this.ready && /not_enough_credit|unauthorized|forbidden|no worker|worker unavailable/i.test(text)) { + this.lastError = classifyZhiziRemoteError(text, this.config.gpuType) + } + if (!this.ready && socket.connected && /GTP ready|beginning main protocol loop/i.test(text)) { + this.markReady() + } + }) + socket.on('connect_error', (cause) => { + if (!isCurrent()) return + this.connectErrorCount += 1 + this.lastError = classifyZhiziRemoteError(cause, this.config.gpuType) + this.state = this.everReady ? 'reconnecting' : 'connecting' + }) + socket.on('disconnect', (reason) => { + if (!isCurrent()) return + this.ready = false + this.disconnectVersion += 1 + this.clearReadyFallback() + this.state = 'reconnecting' + const detail = typeof reason === 'string' ? reason : String(reason ?? '') + const stderrTail = this.stderr.trim().slice(-800) + this.lastError = classifyZhiziRemoteError( + stderrTail || `智子云 Socket 已断开:${detail}`, + this.config.gpuType + ) + }) + } + + private markReady(): void { + this.clearReadyFallback() + this.ready = true + this.everReady = true + this.lastError = null + this.state = 'ready' + } + + private scheduleReadyFallback(socket: ZhiziSocketLike): void { + this.clearReadyFallback() + this.readyFallbackTimer = setTimeout(() => { + if (!this.closed && this.socket === socket && socket.connected && !this.ready && this.everReady) { + this.markReady() + } + }, READY_FALLBACK_MS) + } + + private clearReadyFallback(): void { + if (this.readyFallbackTimer) { + clearTimeout(this.readyFallbackTimer) + this.readyFallbackTimer = null + } + } + + private appendLimited(current: string, next: string): string { + const combined = current + next + return combined.length <= OUTPUT_LIMIT ? combined : combined.slice(-OUTPUT_LIMIT) + } + + private disposeSocket(sendQuit: boolean): void { + this.clearReadyFallback() + const socket = this.socket + this.socket = null + this.ready = false + if (!socket) return + try { + if (sendQuit && socket.connected) socket.emit('stdin', 'quit\n') + } catch { + // Best effort during shutdown. + } + socket.removeAllListeners() + socket.disconnect() + } + + private scheduleIdleClose(): void { + this.clearIdleTimer() + if (this.closed) return + const timeout = this.config.gpuType === 'vip-share' + ? VIP_IDLE_TIMEOUT_MS + : ON_DEMAND_IDLE_TIMEOUT_MS + this.idleTimer = setTimeout(() => this.close(), timeout) + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer) + this.idleTimer = null + } + } +} + +let managedSession: ZhiziPersistentSession | null = null +let managedFingerprint = '' + +function sessionFingerprint(config: ZhiziSessionConfig): string { + return createHash('sha256') + .update(config.accountToken) + .update('\0') + .update(config.args) + .digest('hex') +} + +export function getZhiziPersistentSession(config: ZhiziSessionConfig): ZhiziPersistentSession { + const fingerprint = sessionFingerprint(config) + if (!managedSession || managedSession.isClosed || managedFingerprint !== fingerprint) { + managedSession?.close() + managedSession = new ZhiziPersistentSession(config) + managedFingerprint = fingerprint + } + return managedSession +} + +export function getZhiziPersistentSessionTelemetry(): ZhiziSessionTelemetry { + return managedSession?.telemetry() ?? { + state: 'idle', + connected: false, + ready: false, + reusedConnections: 0, + connectionCount: 0, + lastReadyMillis: 0 + } +} + +export function resetZhiziPersistentSession(): void { + managedSession?.close() + managedSession = null + managedFingerprint = '' +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8a9912e..1e2de34 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -153,6 +153,7 @@ const api = { loginZhiziCloudCode: (payload: ZhiziCloudLoginCodeRequest): Promise => ipcRenderer.invoke('zhizi:login-code', payload), logoutZhiziCloud: (): Promise => ipcRenderer.invoke('zhizi:logout'), testZhiziCloudConnection: (): Promise => ipcRenderer.invoke('zhizi:test-connection'), + enableZhiziCloud: (): Promise => ipcRenderer.invoke('zhizi:enable'), inspectTtsAssets: (): Promise => ipcRenderer.invoke('tts:inspect-assets'), listTtsVoices: (): Promise => ipcRenderer.invoke('tts:list-voices'), synthesizeTts: (payload: TtsSynthesisRequest): Promise => ipcRenderer.invoke('tts:synthesize', payload), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 81818ba..7ba4cb0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -166,6 +166,7 @@ const emptyDashboard: DashboardData = { proxyApiKey: '', proxyModels: [], hasLlmApiKey: false, + hasZhiziToken: false, notes: [] } } @@ -4775,14 +4776,29 @@ function SettingsDrawer({ onDashboardUpdated(result.dashboard) } const detail = result.ok && result.topMove - ? `首选 ${result.topMove}${typeof result.visits === 'number' ? ` · ${result.visits} visits` : ''}` + ? [ + `首选 ${result.topMove}`, + typeof result.visits === 'number' ? `${result.visits} visits` : '', + typeof result.visitsPerSecond === 'number' ? formatSearchSpeed(result.visitsPerSecond) : '' + ].filter(Boolean).join(' · ') : '' - const accountDetail = [ - result.tokenValid ? '主账号 token 有效' : '', - result.isMembership ? `会员有效${result.membershipExpiresAt ? `至 ${new Date(result.membershipExpiresAt).toLocaleDateString()}` : ''}` : '未检测到会员状态', - result.hasConnectAccount ? `连接账号 ${result.connectUsernameMasked || '已绑定'}` : '未绑定连接账号' - ].filter(Boolean).join(' · ') - setZhiziTestMessage(`${result.message}${detail ? `(${detail})` : ''}${accountDetail ? `\n${accountDetail}` : ''}`) + const timingDetail = result.ok + ? [ + result.sessionReused ? '已复用远程引擎' : '', + typeof result.readyMillis === 'number' && result.readyMillis > 0 ? `启动 ${(result.readyMillis / 1000).toFixed(1)} 秒` : '', + typeof result.analysisMillis === 'number' ? `检测 ${(result.analysisMillis / 1000).toFixed(1)} 秒` : '' + ].filter(Boolean).join(' · ') + : '' + const accountDetail = result.tokenValid + ? [ + '主账号 token 有效', + result.isMembership ? `会员有效${result.membershipExpiresAt ? `至 ${new Date(result.membershipExpiresAt).toLocaleDateString()}` : ''}` : '未检测到会员状态', + result.hasConnectAccount ? `连接账号 ${result.connectUsernameMasked || '已绑定'}` : '未绑定连接账号' + ].join(' · ') + : '' + setZhiziTestMessage( + `${result.message}${detail ? `(${detail})` : ''}${timingDetail ? `\n${timingDetail}` : ''}${accountDetail ? `\n${accountDetail}` : ''}` + ) } catch (cause) { setZhiziTestMessage(`智子云连接检测失败:${String(cause).replace(/^Error:\s*/, '')}`) } finally { @@ -4790,19 +4806,51 @@ function SettingsDrawer({ } } + async function enableZhiziCloudConnection(): Promise { + setZhiziTestMessage('') + setZhiziTestBusy(true) + try { + const result = await window.goagent.enableZhiziCloud() + if (result.dashboard) onDashboardUpdated(result.dashboard) + const detail = result.ok && result.topMove + ? [ + `首选 ${result.topMove}`, + typeof result.visits === 'number' ? `${result.visits} visits` : '', + typeof result.visitsPerSecond === 'number' ? formatSearchSpeed(result.visitsPerSecond) : '' + ].filter(Boolean).join(' · ') + : '' + setZhiziTestMessage(`${result.message}${detail ? `(${detail})` : ''}`) + } catch (cause) { + setZhiziTestMessage(`智子云启用失败:${String(cause).replace(/^Error:\s*/, '')}`) + } finally { + setZhiziTestBusy(false) + } + } + async function saveTtsSettings(next: Partial): Promise { const updated = await window.goagent.updateSettings(next) onDashboardUpdated(updated) } const katagoEngineModeForSettings: KataGoEngineMode = - dashboard.settings.katagoEngineMode === 'ikatago' ? 'auto' : dashboard.settings.katagoEngineMode + dashboard.settings.katagoEngineMode === 'ikatago' || dashboard.settings.katagoEngineMode === 'zhizi' + ? 'auto' + : dashboard.settings.katagoEngineMode const zhiziRawStatus = dashboard.systemProfile.katagoStatus ?? '' const zhiziEnabled = dashboard.settings.katagoEngineMode === 'zhizi' + const zhiziLoggedIn = dashboard.systemProfile.hasZhiziToken const zhiziReady = /Zhizi Cloud Direct Ready|智子云直连/i.test(zhiziRawStatus) || (zhiziEnabled && dashboard.systemProfile.katagoReady && /智子|Zhizi/i.test(zhiziRawStatus)) const zhiziNeedsLogin = /Zhizi Cloud Login Required|智子云需登录|智子云未登录/i.test(zhiziRawStatus) - const zhiziStatusLabel = zhiziReady ? '已启用' : zhiziEnabled && zhiziNeedsLogin ? '需登录' : zhiziEnabled ? '待检测' : '未启用' + const zhiziStatusLabel = zhiziReady + ? '已启用' + : zhiziEnabled && zhiziNeedsLogin + ? '需登录' + : zhiziEnabled + ? '待检测' + : zhiziLoggedIn + ? '已登录' + : '未登录' const zhiziStatusClassName = zhiziReady ? 'settings-status-chip is-ready' : zhiziNeedsLogin ? 'settings-status-chip is-warning' : 'settings-status-chip' const llmReady = dashboard.systemProfile.hasLlmApiKey const katagoReady = Boolean(katagoAssets?.ready || dashboard.systemProfile.katagoReady) @@ -5077,7 +5125,7 @@ function SettingsDrawer({
默认本机,远程手动启用 -

本机分析不上传棋谱。需要智子云时,先登录账号,再点击启用直连;不用时点“回到本地分析”。

+

本机分析不上传棋谱。需要智子云时,先登录账号,再点击“检测并启用”;不用时点“回到本地分析”。

  1. 1登录账号
  2. @@ -5096,7 +5144,6 @@ function SettingsDrawer({ - 默认本机不会上传局面;只有选择或启用智子云直连时才会使用远程算力。 @@ -5132,10 +5179,10 @@ function SettingsDrawer({ - {zhiziEnabled ? '当前已选择智子云。若分析提示需登录,请先完成下面的账号登录。' : '当前使用本机 KataGo,不会连接智子云。'} + + {zhiziEnabled + ? '当前使用智子云分析。切回本机后会立即释放远程连接。' + : zhiziLoggedIn + ? '账号已登录。检测通过后才会切换远程分析,失败不会影响本机 KataGo。' + : '请先登录智子云。当前仍使用本机 KataGo,不会上传局面。'} +
- {zhiziTestMessage ?
{zhiziTestMessage}
: null} + {zhiziTestMessage ?
{zhiziTestMessage}
: null}

登录智子云

请使用智子云主账号手机号或邮箱登录。zz- 开头的是连接账号,不能直接换取 GoAgent 远程算力 token。

@@ -5194,9 +5247,9 @@ function SettingsDrawer({ disabled={busy !== '' || zhiziLoginBusy || !zhiziUsernameInput.trim() || !zhiziLoginPassword.trim()} onClick={() => void loginZhiziCloud()} > - {zhiziLoginBusy ? '正在登录智子云...' : '登录并连接智子云'} + {zhiziLoginBusy ? '正在登录智子云...' : '登录智子云'} - 密码只用于本次登录。手机号和邮箱可登录;zz- 连接账号需要先在智子官方账号体系里绑定。 + 密码只用于本次登录。登录后再点击上方“检测并启用”,确认远程引擎可用后才会切换。