diff --git a/src/main/index.ts b/src/main/index.ts index 55054e9..bd4b926 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell, type Conte import { isAbsolute, relative, resolve, join } from 'node:path' import { appHome, findGame, getGames, getIkatagoPassword, getSettings, getTtsCustomApiKey, getTtsVolcengineAccessToken, getTtsVolcengineApiKey, getZhiziToken, hasIkatagoPassword, hasLlmApiKey, hasTtsCustomApiKey, hasTtsVolcengineAccessToken, hasTtsVolcengineApiKey, hasZhiziToken, replaceSettings, setSettings, upsertGames } from './lib/store' import { BRAND_NAME } from '@shared/brand' -import type { AnalyzeGameQuickRequest, AnalyzePositionRequest, AnalyzeTrialPositionRequest, AppSettings, DashboardData, FoxSyncRequest, KataGoAssetInstallRequest, KataGoBenchmarkRequest, KataGoCancelAnalysisRequest, LibraryDeleteRequest, LlmModelsListRequest, LlmSettingsTestRequest, ReviewRequest, TeacherBoardImageRenderImage, TeacherBoardImageRenderRequest, TeacherBoardImageRenderResponse, TeacherChatMessage, TeacherRunCancelRequest, TeacherRunRequest, ZhiziCloudConnectionTestResult, ZhiziCloudLoginCodeRequest, ZhiziCloudLoginRequest, ZhiziCloudLoginResult, ZhiziCloudSendCodeRequest, ZhiziCloudSendCodeResult } from './lib/types' +import type { AnalyzeGameQuickRequest, AnalyzePositionRequest, AnalyzeTrialPositionRequest, AppSettings, DashboardData, FoxSyncRequest, KataGoAssetInstallRequest, KataGoBenchmarkCancelRequest, KataGoBenchmarkRequest, KataGoBenchmarkStartRequest, KataGoCancelAnalysisRequest, LibraryDeleteRequest, LlmModelsListRequest, LlmSettingsTestRequest, ReviewRequest, TeacherBoardImageRenderImage, TeacherBoardImageRenderRequest, TeacherBoardImageRenderResponse, TeacherChatMessage, TeacherRunCancelRequest, TeacherRunRequest, ZhiziCloudConnectionTestResult, ZhiziCloudLoginCodeRequest, ZhiziCloudLoginRequest, ZhiziCloudLoginResult, ZhiziCloudSendCodeRequest, ZhiziCloudSendCodeResult } from './lib/types' import { importSgfFile, readGameRecord } from './services/sgf' import { ensureFoxGameDownloaded, syncFoxGames } from './services/fox' import { runReview } from './services/review' @@ -10,13 +10,13 @@ import { applyDetectedDefaults, detectSystemProfile } from './services/systemPro import { cancelTeacherRun, runTeacherTask } from './services/teacherAgent' import { listLlmModels, testLlmSettings } from './services/llm' import { analyzeTrialPositionWithProgress, cancelKataGoAnalysis } from './services/katago' -import { benchmarkKataGo } from './services/katagoBenchmark' +import { benchmarkKataGo, cancelKataGoBenchmark, startKataGoBenchmark } from './services/katagoBenchmark' import { getKataGoEnginePoolStats } from './services/katagoEnginePool' import { 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' -import { inspectKataGoAssets, installOfficialKataGoModel } from './services/katago/katagoAssets' +import { cancelKataGoAssetInstall, inspectKataGoAssets, installOfficialKataGoModel } from './services/katago/katagoAssets' import { bindFoxGamesToStudent, bindSgfGameToStudent, suggestStudentBindings } from './services/library/studentBinding' import { deleteLibraryGame } from './services/library/deleteGame' import { inspectReleaseReadiness } from './services/release/readiness' @@ -381,6 +381,7 @@ app.whenReady().then(() => { safeSendToRenderer(event, 'katago-assets:install-progress', progress) }) ) + ipcMain.handle('katago-assets:cancel-install', () => ({ cancelled: cancelKataGoAssetInstall() })) ipcMain.handle('student:list', async () => listStudents()) ipcMain.handle('student:suggest-bindings', async (_event, payload) => suggestStudentBindings(payload)) ipcMain.handle('student:bind-sgf-game', async (_event, payload) => bindSgfGameToStudent(payload)) @@ -518,6 +519,12 @@ app.whenReady().then(() => { ipcMain.handle('analysis-scheduler:stats', async () => getAnalysisSchedulerStats()) ipcMain.handle('katago:engine-pool-stats', async () => getKataGoEnginePoolStats()) ipcMain.handle('katago:benchmark', async (_event, payload: KataGoBenchmarkRequest | undefined) => benchmarkKataGo(payload ?? {})) + ipcMain.handle('katago:benchmark-start', (event, payload: KataGoBenchmarkStartRequest | undefined) => + startKataGoBenchmark(payload ?? {}, (progress) => safeSendToRenderer(event, 'katago:benchmark-progress', progress)) + ) + ipcMain.handle('katago:benchmark-cancel', (_event, payload: KataGoBenchmarkCancelRequest | undefined) => + cancelKataGoBenchmark(payload ?? {}) + ) ipcMain.handle('teacher:run', async (event, payload: TeacherRunRequest) => runTeacherTask(payload, (progress) => { safeSendToRenderer(event, 'teacher:run-progress', progress) diff --git a/src/main/lib/store.ts b/src/main/lib/store.ts index 835b446..dcfe758 100644 --- a/src/main/lib/store.ts +++ b/src/main/lib/store.ts @@ -24,6 +24,26 @@ function defaultPythonBin(): string { return process.platform === 'win32' ? 'python' : 'python3' } +function defaultReviewLanguage(): AppSettings['reviewLanguage'] { + let systemLocale = '' + try { + systemLocale = app.getLocale() + } catch { + // Electron may not expose app locale before ready on every platform. + } + const locale = (systemLocale || Intl.DateTimeFormat().resolvedOptions().locale).replaceAll('_', '-').toLowerCase() + const traditionalChinese = + (locale.startsWith('zh') || locale.startsWith('yue')) && + (locale.startsWith('yue') || locale.includes('hant') || /-(tw|hk|mo)(?:-|\.|$)/.test(locale)) + if (traditionalChinese) return 'zh-TW' + if (locale.startsWith('zh')) return 'zh-CN' + if (locale.startsWith('ja')) return 'ja-JP' + if (locale.startsWith('ko')) return 'ko-KR' + if (locale.startsWith('th')) return 'th-TH' + if (locale.startsWith('vi')) return 'vi-VN' + return 'en-US' +} + const defaults: AppSettings = { katagoBin: '', katagoConfig: '', @@ -36,6 +56,9 @@ const defaults: AppSettings = { katagoBenchmarkThreads: 0, katagoBenchmarkVisitsPerSecond: 0, katagoBenchmarkUpdatedAt: '', + katagoBenchmarkEngineFingerprint: '', + katagoBenchmarkLastCompletedAt: '', + katagoAutoBenchmarkEnabled: true, katagoEngineMode: 'auto', katagoAnalysisSpeedMode: 'auto', localAnalysisDefaultApplied: false, @@ -57,7 +80,10 @@ const defaults: AppSettings = { llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', llmModel: 'gpt-5-mini', - reviewLanguage: 'zh-CN', + onboardingVersion: 0, + llmSetupStatus: 'unconfigured', + llmLastVerifiedAt: '', + reviewLanguage: defaultReviewLanguage(), defaultPlayerName: '', ttsEnabled: true, ttsAutoPlay: false, @@ -347,6 +373,18 @@ export function setSettings(next: Partial): AppSettings { Object.prototype.hasOwnProperty.call(safeNext, 'ikatagoUseWhenLocalSlow') || Object.prototype.hasOwnProperty.call(safeNext, 'zhiziUseWhenLocalSlow') settingsStore.set(shouldMarkLocalDefaultApplied ? { ...safeNext, localAnalysisDefaultApplied: true } : safeNext) + const llmConfigChanged = + Object.prototype.hasOwnProperty.call(next, 'llmBaseUrl') || + Object.prototype.hasOwnProperty.call(next, 'llmApiKey') || + Object.prototype.hasOwnProperty.call(next, 'llmModel') + if (llmConfigChanged && !Object.prototype.hasOwnProperty.call(next, 'llmSetupStatus')) { + const current = getSettings() + const configured = Boolean(current.llmBaseUrl.trim() && current.llmApiKey.trim() && current.llmModel.trim()) + settingsStore.set({ + llmSetupStatus: configured ? 'needs-attention' : 'unconfigured', + llmLastVerifiedAt: '' + }) + } return getSettings() } diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index 01bf951..b5c19c2 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -81,6 +81,8 @@ export interface VisionEvidenceReport { createdAt: string } +export type LlmSetupStatus = 'unconfigured' | 'verified' | 'skipped' | 'needs-attention' + export interface AppSettings { katagoBin: string katagoConfig: string @@ -93,6 +95,9 @@ export interface AppSettings { katagoBenchmarkThreads: number katagoBenchmarkVisitsPerSecond: number katagoBenchmarkUpdatedAt: string + katagoBenchmarkEngineFingerprint: string + katagoBenchmarkLastCompletedAt: string + katagoAutoBenchmarkEnabled: boolean katagoEngineMode: KataGoEngineMode katagoAnalysisSpeedMode: KataGoAnalysisSpeedMode localAnalysisDefaultApplied: boolean @@ -114,6 +119,9 @@ export interface AppSettings { llmBaseUrl: string llmApiKey: string llmModel: string + onboardingVersion: number + llmSetupStatus: LlmSetupStatus + llmLastVerifiedAt: string reviewLanguage: 'zh-CN' | 'zh-TW' | 'en-US' | 'ja-JP' | 'ko-KR' | 'th-TH' | 'vi-VN' defaultPlayerName: string ttsEnabled: boolean @@ -370,6 +378,36 @@ export interface KataGoBenchmarkRequest { numPositions?: number secondsPerMove?: number threads?: number[] + timeoutMs?: number +} + +export type KataGoBenchmarkTaskStatus = 'running' | 'completed' | 'cancelled' | 'timed-out' | 'failed' | 'skipped' + +export interface KataGoBenchmarkStartRequest extends KataGoBenchmarkRequest { + automatic?: boolean + onlyIfNeeded?: boolean +} + +export interface KataGoBenchmarkStartResult { + runId: string + status: 'running' | 'skipped' + message: string +} + +export interface KataGoBenchmarkCancelRequest { + runId?: string +} + +export interface KataGoBenchmarkCancelResult { + cancelled: boolean + runId?: string +} + +export interface KataGoBenchmarkProgress { + runId: string + status: KataGoBenchmarkTaskStatus + message: string + result?: KataGoBenchmarkResult } export interface KataGoBenchmarkThreadResult { @@ -388,13 +426,14 @@ export interface KataGoBenchmarkResult { command: string outputTail: string updatedAt: string + engineFingerprint: string } export interface KataGoAssetInstallRequest { presetId?: KataGoModelPresetId } -export type KataGoAssetInstallStage = 'discovering' | 'downloading-binary' | 'downloading-model' | 'copying-binary' | 'writing-manifest' | 'done' | 'error' +export type KataGoAssetInstallStage = 'discovering' | 'downloading-binary' | 'downloading-model' | 'copying-binary' | 'writing-manifest' | 'paused' | 'done' | 'error' export interface KataGoAssetInstallProgress { stage: KataGoAssetInstallStage @@ -414,6 +453,10 @@ export interface KataGoAssetInstallResult { detail: string } +export interface KataGoAssetInstallCancelResult { + cancelled: boolean +} + export type ReleaseReadinessStatus = 'pass' | 'warn' | 'fail' | 'unknown' export interface ReleaseReadinessItem { @@ -1227,6 +1270,17 @@ export interface LlmSettingsTestRequest { export interface LlmSettingsTestResult { ok: boolean message: string + capabilities: { + text: LlmCapabilityCheck + vision: LlmCapabilityCheck + tools: LlmCapabilityCheck + } +} + +export interface LlmCapabilityCheck { + ok: boolean + message: string + technicalDetail?: string } export interface LlmModelsListRequest { diff --git a/src/main/services/analysis/scheduler.ts b/src/main/services/analysis/scheduler.ts index 2061231..d36bd92 100644 --- a/src/main/services/analysis/scheduler.ts +++ b/src/main/services/analysis/scheduler.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import type { KataGoAnalysisGroup } from '@main/lib/types' import { cancelKataGoAnalysis } from '../katago' +import { cancelKataGoBenchmark } from '../katagoBenchmark' export type ScheduledAnalysisPriority = 'live' | 'teacher' | 'quick' | 'background' @@ -133,6 +134,7 @@ function pump(): void { } export function runScheduledAnalysis(input: ScheduledAnalysisInput, task: () => Promise): Promise { + cancelKataGoBenchmark() const id = input.runId || randomUUID() const priority = input.priority ?? priorityForGroup(input.group) if (input.replaceGroup && input.group) { diff --git a/src/main/services/diagnostics/index.ts b/src/main/services/diagnostics/index.ts index 45065f0..bb10e02 100644 --- a/src/main/services/diagnostics/index.ts +++ b/src/main/services/diagnostics/index.ts @@ -5,7 +5,6 @@ import { appHome, getSettings, hasLlmApiKey } from '@main/lib/store' import { resolveKataGoRuntime } from '../katagoRuntime' import { ikatagoClientConfigured, shouldPreferIKataGoEngine } from '../ikatagoClientEngine' import { shouldPreferZhiziGtpEngine, zhiziGtpConfigured } from '../zhiziGtpEngine' -import { probeOpenAICompatibleProvider } from '../llm/openaiCompatibleProvider' import { inspectKataGoAssets } from '../katago/katagoAssets' import { isLitePackagedRuntime } from '../release/packageRuntime' import type { DiagnosticCheck, DiagnosticsReport, DiagnosticsOverall } from './types' @@ -229,26 +228,21 @@ async function checkLlmProxy(): Promise { if (!configured) { return { id: 'llm-proxy', - title: 'Claude 兼容代理', + title: 'AI 老师', status: 'warn', required: false, - detail: '还没有配置 Claude 兼容代理。KataGo 基础分析可用,但老师讲解不可用。', - action: '在设置中填写 Base URL、API Key 和 Claude 模型名。' + detail: '还没有连接 AI 模型。KataGo 分析仍然可以正常使用。', + action: '在“设置 > AI 模型”中填写服务地址、访问密钥和模型。' } } - const result = await probeOpenAICompatibleProvider({ - llmBaseUrl: settings.llmBaseUrl, - llmApiKey: settings.llmApiKey, - llmModel: settings.llmModel - }) + const verified = settings.llmSetupStatus === 'verified' return { id: 'llm-proxy', - title: 'Claude 兼容代理', - status: result.ok ? 'pass' : 'warn', + title: 'AI 老师', + status: verified ? 'pass' : 'warn', required: false, - detail: result.message, - action: result.ok ? undefined : '请检查代理是否启动、API Key 是否正确、模型是否支持图片输入。', - technicalDetail: result.technicalDetail + detail: verified ? '文字、棋盘图片和 Agent 工具调用已通过验证。' : '配置已保存,但还没有完成能力验证。', + action: verified ? undefined : '打开“设置 > AI 模型”,运行连接验证。' } } diff --git a/src/main/services/katago/katagoAssets.ts b/src/main/services/katago/katagoAssets.ts index dd00de3..4e16a95 100644 --- a/src/main/services/katago/katagoAssets.ts +++ b/src/main/services/katago/katagoAssets.ts @@ -56,6 +56,14 @@ interface KataGoEditionMetadata { const execFileAsync = promisify(execFile) const WINDOWS_OPENCL_RUNTIME_URL = 'https://github.com/wimi321/lizzieyzy-next/releases/download/1.0.0-next-2026-05-02.3/2026-05-02-windows64.opencl.portable.zip' +let activeInstallController: AbortController | null = null + +class KataGoAssetInstallPausedError extends Error { + constructor() { + super('KataGo 资源下载已暂停。再次点击下载即可继续。') + this.name = 'KataGoAssetInstallPausedError' + } +} function platformKey(): string { return `${process.platform}-${process.arch}` @@ -134,7 +142,7 @@ function absoluteUrl(value: string): string { return new URL(value, 'https://katagotraining.org').toString() } -async function discoverModelDownloadUrl(presetId?: string): Promise { +async function discoverModelDownloadUrl(presetId?: string, signal?: AbortSignal): Promise { const preset = getKataGoModelPreset(presetId) if (preset.downloadUrl) { return preset.downloadUrl @@ -145,7 +153,8 @@ async function discoverModelDownloadUrl(presetId?: string): Promise { const fallback = `https://media.katagotraining.org/uploaded/networks/models/kata1/${preset.fileName}` try { const response = await fetch(preset.sourceUrl, { - headers: { 'User-Agent': 'GoAgent KataGo asset installer' } + headers: { 'User-Agent': 'GoAgent KataGo asset installer' }, + signal }) if (!response.ok) { return fallback @@ -188,7 +197,8 @@ async function downloadFile( exists: '官方权重已存在,跳过下载。', active: '正在下载 KataGo 官方权重。', done: '官方权重下载完成。' - } + }, + signal?: AbortSignal ): Promise { const targetExists = await exists(target) if (targetExists) { @@ -197,15 +207,35 @@ async function downloadFile( } const tmp = `${target}.download` await mkdir(dirname(target), { recursive: true }) - await unlink(tmp).catch(() => undefined) - const response = await fetch(url, { - headers: { 'User-Agent': 'GoAgent KataGo asset installer' } + let existingBytes = await stat(tmp).then((value) => value.size).catch(() => 0) + const request = async (resumeAt: number): Promise => fetch(url, { + headers: { + 'User-Agent': 'GoAgent KataGo asset installer', + ...(resumeAt > 0 ? { Range: `bytes=${resumeAt}-` } : {}) + }, + signal }) + let response = await request(existingBytes) + if (response.status === 416 && existingBytes > 0) { + await unlink(tmp).catch(() => undefined) + existingBytes = 0 + response = await request(0) + } if (!response.ok || !response.body) { throw new Error(`官方权重下载失败: HTTP ${response.status}`) } - const totalBytes = Number(response.headers.get('content-length') ?? 0) || undefined - let receivedBytes = 0 + const resumed = existingBytes > 0 && response.status === 206 + const contentRangeTotal = Number(/\/(\d+)$/.exec(response.headers.get('content-range') ?? '')?.[1] ?? 0) || undefined + const responseBytes = Number(response.headers.get('content-length') ?? 0) || undefined + const totalBytes = contentRangeTotal ?? (responseBytes ? (resumed ? existingBytes + responseBytes : responseBytes) : undefined) + let receivedBytes = resumed ? existingBytes : 0 + onProgress?.({ + stage, + message: resumed ? `${messages.active} 已从 ${Math.round(existingBytes / 1024 / 1024)} MB 处继续。` : messages.active, + receivedBytes, + totalBytes, + percent: progressPercent(receivedBytes, totalBytes) + }) const progressStream = new Transform({ transform(chunk: Buffer, _encoding, callback) { receivedBytes += chunk.length @@ -219,7 +249,19 @@ async function downloadFile( callback(null, chunk) } }) - await pipeline(Readable.fromWeb(response.body as never), progressStream, createWriteStream(tmp)) + try { + await pipeline( + Readable.fromWeb(response.body as never), + progressStream, + createWriteStream(tmp, { flags: resumed ? 'a' : 'w' }), + { signal } + ) + } catch (error) { + if (signal?.aborted) { + throw new KataGoAssetInstallPausedError() + } + throw error + } await rename(tmp, target) onProgress?.({ stage, message: messages.done, receivedBytes, totalBytes, percent: 100 }) return true @@ -254,7 +296,8 @@ async function downloadPlatformRuntimeIfAvailable( root: string, manifest: KataGoAssetManifest, key: string, - onProgress?: (progress: KataGoAssetInstallProgress) => void + onProgress?: (progress: KataGoAssetInstallProgress) => void, + signal?: AbortSignal ): Promise<{ path: string; copied: boolean }> { const platform = manifest.supportedPlatforms[key] if (!platform) { @@ -274,7 +317,7 @@ async function downloadPlatformRuntimeIfAvailable( exists: 'KataGo Windows OpenCL 运行库已下载,跳过下载。', active: '正在下载 KataGo Windows OpenCL 运行库。', done: 'KataGo Windows OpenCL 运行库下载完成。' - }) + }, signal) const extractionDir = join(root, 'runtime-downloads', 'win32-x64-opencl') await rm(extractionDir, { recursive: true, force: true }) @@ -299,7 +342,8 @@ async function copyPlatformBinaryIfAvailable( root: string, manifest: KataGoAssetManifest, key: string, - onProgress?: (progress: KataGoAssetInstallProgress) => void + onProgress?: (progress: KataGoAssetInstallProgress) => void, + signal?: AbortSignal ): Promise<{ path: string; copied: boolean }> { const platform = manifest.supportedPlatforms[key] if (!platform) { @@ -316,7 +360,7 @@ async function copyPlatformBinaryIfAvailable( .filter((candidateRoot) => candidateRoot !== root) .map((candidateRoot) => join(candidateRoot, platform.binaryPath))) if (!source) { - return downloadPlatformRuntimeIfAvailable(root, manifest, key, onProgress) + return downloadPlatformRuntimeIfAvailable(root, manifest, key, onProgress, signal) } await mkdir(dirname(target), { recursive: true }) await copyFile(source, target) @@ -431,9 +475,10 @@ async function findBundledModelPath(preset: { id: string; fileName: string; netw return '' } -export async function installOfficialKataGoModel( +async function installOfficialKataGoModelInternal( request: KataGoAssetInstallRequest = {}, - onProgress?: (progress: KataGoAssetInstallProgress) => void + onProgress?: (progress: KataGoAssetInstallProgress) => void, + signal?: AbortSignal ): Promise { const key = platformKey() const userRoot = userKatagoRoot() @@ -458,7 +503,7 @@ export async function installOfficialKataGoModel( modelPath: `models/${preset.fileName}`, modelSha256: await sha256(bundledMatch).catch(() => '') } - const binary = await copyPlatformBinaryIfAvailable(userRoot, manifest, key, onProgress) + const binary = await copyPlatformBinaryIfAvailable(userRoot, manifest, key, onProgress, signal) onProgress?.({ stage: 'writing-manifest', message: '正在写入本机 KataGo 资源配置。' }) await mkdir(userRoot, { recursive: true }) await writeFile(join(userRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') @@ -479,9 +524,9 @@ export async function installOfficialKataGoModel( } onProgress?.({ stage: 'discovering', message: `准备安装 ${preset.label}。` }) - const downloadUrl = await discoverModelDownloadUrl(preset.id) + const downloadUrl = await discoverModelDownloadUrl(preset.id, signal) const modelPath = join(userRoot, 'models', preset.fileName) - const downloadedModel = await downloadFile(downloadUrl, modelPath, onProgress) + const downloadedModel = await downloadFile(downloadUrl, modelPath, onProgress, 'downloading-model', undefined, signal) onProgress?.({ stage: 'copying-binary', message: '正在检查当前平台 KataGo 引擎。' }) const manifest: KataGoAssetManifest = { @@ -492,7 +537,7 @@ export async function installOfficialKataGoModel( modelPath: `models/${preset.fileName}`, modelSha256: await sha256(modelPath).catch(() => '') } - const binary = await copyPlatformBinaryIfAvailable(userRoot, manifest, key, onProgress) + const binary = await copyPlatformBinaryIfAvailable(userRoot, manifest, key, onProgress, signal) onProgress?.({ stage: 'writing-manifest', message: '正在写入本机 KataGo 资源配置。' }) await mkdir(userRoot, { recursive: true }) await writeFile(join(userRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') @@ -512,3 +557,36 @@ export async function installOfficialKataGoModel( detail } } + +export function cancelKataGoAssetInstall(): boolean { + if (!activeInstallController || activeInstallController.signal.aborted) { + return false + } + activeInstallController.abort() + return true +} + +export async function installOfficialKataGoModel( + request: KataGoAssetInstallRequest = {}, + onProgress?: (progress: KataGoAssetInstallProgress) => void +): Promise { + if (activeInstallController && !activeInstallController.signal.aborted) { + throw new Error('已有 KataGo 资源下载正在进行。') + } + const controller = new AbortController() + activeInstallController = controller + try { + return await installOfficialKataGoModelInternal(request, onProgress, controller.signal) + } catch (error) { + if (controller.signal.aborted || error instanceof KataGoAssetInstallPausedError) { + const paused = new KataGoAssetInstallPausedError() + onProgress?.({ stage: 'paused', message: paused.message }) + throw paused + } + throw error + } finally { + if (activeInstallController === controller) { + activeInstallController = null + } + } +} diff --git a/src/main/services/katagoBenchmark.ts b/src/main/services/katagoBenchmark.ts index 5226279..fef7b94 100644 --- a/src/main/services/katagoBenchmark.ts +++ b/src/main/services/katagoBenchmark.ts @@ -1,15 +1,47 @@ -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync } from 'node:fs' +import { spawn, type ChildProcess } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { mkdirSync, statSync, writeFileSync } from 'node:fs' import os from 'node:os' import { basename, join } from 'node:path' import { appHome, getSettings, setSettings } from '@main/lib/store' -import type { AppSettings, KataGoBenchmarkRequest, KataGoBenchmarkResult, KataGoBenchmarkThreadResult } from '@main/lib/types' +import type { + AppSettings, + KataGoBenchmarkCancelRequest, + KataGoBenchmarkCancelResult, + KataGoBenchmarkProgress, + KataGoBenchmarkRequest, + KataGoBenchmarkResult, + KataGoBenchmarkStartRequest, + KataGoBenchmarkStartResult, + KataGoBenchmarkThreadResult +} from '@main/lib/types' import { resolveKataGoRuntime } from './katagoRuntime' -function saneInteger(value: number | undefined, fallback: number, min: number, max: number): number { - if (!Number.isFinite(value) || !value) { - return fallback +class BenchmarkCancelledError extends Error { + constructor(message = 'KataGo 测速已取消。') { + super(message) + this.name = 'BenchmarkCancelledError' + } +} + +class BenchmarkTimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'BenchmarkTimeoutError' } +} + +interface ActiveBenchmark { + runId: string + child: ChildProcess | null + controller: AbortController + notify: (progress: KataGoBenchmarkProgress) => void +} + +let activeBenchmark: ActiveBenchmark | null = null + +function saneInteger(value: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value) || !value) return fallback return Math.max(min, Math.min(max, Math.round(value))) } @@ -31,63 +63,77 @@ function writeBenchmarkConfig(settings: AppSettings): string { const configPath = join(configDir, 'benchmark_builtin.cfg') const currentThreads = saneInteger(settings.katagoBenchmarkThreads, settings.katagoAnalysisThreads || 2, 1, 64) const cacheSizePowerOfTwo = saneInteger(settings.katagoCacheSizePowerOfTwo, 20, 16, 28) - writeFileSync( - configPath, - [ - `logDir = ${logDir}`, - 'logAllRequests = false', - 'logSearchInfo = false', - `numSearchThreads = ${currentThreads}`, - `nnCacheSizePowerOfTwo = ${cacheSizePowerOfTwo}`, - '' - ].join('\n'), - 'utf8' - ) + writeFileSync(configPath, [ + `logDir = ${logDir}`, + 'logAllRequests = false', + 'logSearchInfo = false', + `numSearchThreads = ${currentThreads}`, + `nnCacheSizePowerOfTwo = ${cacheSizePowerOfTwo}`, + '' + ].join('\n'), 'utf8') return configPath } -function runBenchmarkCommand(command: string, args: string[], timeoutMs: number): Promise { +function fileFingerprint(path: string): string { + try { + const stat = statSync(path) + return `${path}:${stat.size}:${Math.trunc(stat.mtimeMs)}` + } catch { + return `${path}:missing` + } +} + +export function kataGoBenchmarkFingerprint(settings: AppSettings = getSettings()): string { + const runtime = resolveKataGoRuntime(settings) + return createHash('sha256').update([ + process.platform, + process.arch, + settings.katagoModelPreset, + fileFingerprint(runtime.katagoBin), + fileFingerprint(runtime.katagoModel) + ].join('|')).digest('hex').slice(0, 24) +} + +function runBenchmarkCommand( + command: string, + args: string[], + timeoutMs: number, + signal?: AbortSignal, + onChild?: (child: ChildProcess) => void +): Promise { + if (signal?.aborted) return Promise.reject(new BenchmarkCancelledError()) const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + onChild?.(child) let output = '' - child.stdout.on('data', (chunk) => { - output += String(chunk) - }) - child.stderr.on('data', (chunk) => { - output += String(chunk) - }) + child.stdout?.on('data', (chunk) => { output += String(chunk) }) + child.stderr?.on('data', (chunk) => { output += String(chunk) }) return new Promise((resolve, reject) => { let settled = false - const timer = setTimeout(() => { - if (settled) { - return - } + const settle = (callback: () => void): void => { + if (settled) return settled = true + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + callback() + } + const abort = (): void => { child.kill() - reject(new Error(`KataGo benchmark 超时。\n${tail(output)}`)) + settle(() => reject(new BenchmarkCancelledError())) + } + const timer = setTimeout(() => { + child.kill() + settle(() => reject(new BenchmarkTimeoutError(`KataGo benchmark 超时。\n${tail(output)}`))) }, timeoutMs) - - child.once('error', (error) => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - reject(error) - }) - - child.once('close', (code) => { - if (settled) { - return - } - settled = true - clearTimeout(timer) + signal?.addEventListener('abort', abort, { once: true }) + child.once('error', (error) => settle(() => reject(error))) + child.once('close', (code) => settle(() => { if (code !== 0 && code !== null) { reject(new Error(tail(output) || `KataGo benchmark exited with ${code}`)) return } resolve(output) - }) + })) }) } @@ -96,28 +142,24 @@ function parseBenchmarkOutput(output: string): { results: KataGoBenchmarkThreadR const byThread = new Map() for (const line of normalized.split('\n')) { const match = line.match(/numSearchThreads\s*=\s*(\d+):.*?visits\/s\s*=\s*([0-9]+(?:\.[0-9]+)?)/) - if (!match) { - continue - } + if (!match) continue const threads = Number.parseInt(match[1], 10) const visitsPerSecond = Number.parseFloat(match[2]) if (Number.isFinite(threads) && Number.isFinite(visitsPerSecond)) { byThread.set(threads, { threads, visitsPerSecond }) } } - const recommendedMatches = [...normalized.matchAll(/numSearchThreads\s*=\s*(\d+):[^\n]*\(recommended\)/g)] const recommendedThreads = recommendedMatches.length ? Number.parseInt(recommendedMatches[recommendedMatches.length - 1][1], 10) : undefined - return { results: [...byThread.values()].sort((a, b) => a.threads - b.threads), recommendedThreads: Number.isFinite(recommendedThreads) ? recommendedThreads : undefined } } -function tunedSettings(recommendedThreads: number, visitsPerSecond: number): Pick< +function tunedSettings(recommendedThreads: number, visitsPerSecond: number, fingerprint: string): Pick< AppSettings, | 'katagoAnalysisThreads' | 'katagoSearchThreadsPerAnalysisThread' @@ -126,10 +168,13 @@ function tunedSettings(recommendedThreads: number, visitsPerSecond: number): Pic | 'katagoBenchmarkThreads' | 'katagoBenchmarkVisitsPerSecond' | 'katagoBenchmarkUpdatedAt' + | 'katagoBenchmarkEngineFingerprint' + | 'katagoBenchmarkLastCompletedAt' > { const analysisThreads = Math.max(1, Math.min(4, recommendedThreads)) const searchThreadsPerAnalysisThread = Math.max(1, Math.round(recommendedThreads / analysisThreads)) const maxBatchSize = Math.max(16, Math.min(128, recommendedThreads >= 12 ? 64 : 32)) + const completedAt = new Date().toISOString() return { katagoAnalysisThreads: analysisThreads, katagoSearchThreadsPerAnalysisThread: searchThreadsPerAnalysisThread, @@ -137,7 +182,9 @@ function tunedSettings(recommendedThreads: number, visitsPerSecond: number): Pic katagoCacheSizePowerOfTwo: 20, katagoBenchmarkThreads: recommendedThreads, katagoBenchmarkVisitsPerSecond: visitsPerSecond, - katagoBenchmarkUpdatedAt: new Date().toISOString() + katagoBenchmarkUpdatedAt: completedAt, + katagoBenchmarkEngineFingerprint: fingerprint, + katagoBenchmarkLastCompletedAt: completedAt } } @@ -145,47 +192,36 @@ function tail(text: string, maxLines = 36): string { return text.replace(/\r/g, '\n').split('\n').slice(-maxLines).join('\n').trim() } -export async function benchmarkKataGo(request: KataGoBenchmarkRequest = {}): Promise { +async function executeBenchmark( + request: KataGoBenchmarkRequest = {}, + signal?: AbortSignal, + onChild?: (child: ChildProcess) => void +): Promise { const settings = getSettings() const runtime = resolveKataGoRuntime(settings) - if (!runtime.ready) { - throw new Error(`${runtime.status}: ${runtime.notes.join(';')}`) - } - + if (!runtime.ready) throw new Error(`${runtime.status}: ${runtime.notes.join(';')}`) + const fingerprint = kataGoBenchmarkFingerprint(settings) const benchmarkConfig = writeBenchmarkConfig(settings) const visits = saneInteger(request.visits, 160, 16, 2000) const numPositions = saneInteger(request.numPositions, 4, 1, 20) const secondsPerMove = saneInteger(request.secondsPerMove, 5, 1, 60) const threadCandidates = benchmarkThreadCandidates(request.threads) const args = [ - 'benchmark', - '-model', - runtime.katagoModel, - '-config', - benchmarkConfig, - '-v', - String(visits), - '-n', - String(numPositions), - '-time', - String(secondsPerMove), - '-t', - threadCandidates.join(',') + 'benchmark', '-model', runtime.katagoModel, '-config', benchmarkConfig, + '-v', String(visits), '-n', String(numPositions), '-time', String(secondsPerMove), + '-t', threadCandidates.join(',') ] - - const output = await runBenchmarkCommand(runtime.katagoBin, args, Math.max(90_000, threadCandidates.length * numPositions * 12_000)) + const defaultTimeout = Math.max(90_000, threadCandidates.length * numPositions * 12_000) + const timeoutMs = saneInteger(request.timeoutMs, defaultTimeout, 5_000, 15 * 60_000) + const output = await runBenchmarkCommand(runtime.katagoBin, args, timeoutMs, signal, onChild) const parsed = parseBenchmarkOutput(output) - if (parsed.results.length === 0) { - throw new Error(`KataGo benchmark 没有返回速度结果。\n${tail(output)}`) - } - + if (parsed.results.length === 0) throw new Error(`KataGo benchmark 没有返回速度结果。\n${tail(output)}`) const fastest = parsed.results.reduce((best, item) => item.visitsPerSecond > best.visitsPerSecond ? item : best, parsed.results[0]) const recommended = parsed.recommendedThreads ? parsed.results.find((item) => item.threads === parsed.recommendedThreads) ?? fastest : fastest - const next = tunedSettings(recommended.threads, recommended.visitsPerSecond) + const next = tunedSettings(recommended.threads, recommended.visitsPerSecond, fingerprint) setSettings(next) - return { recommendedThreads: recommended.threads, visitsPerSecond: recommended.visitsPerSecond, @@ -196,6 +232,67 @@ export async function benchmarkKataGo(request: KataGoBenchmarkRequest = {}): Pro cacheSizePowerOfTwo: next.katagoCacheSizePowerOfTwo, command: `${basename(runtime.katagoBin)} ${args.map((arg) => arg.includes(' ') ? JSON.stringify(arg) : arg).join(' ')}`, outputTail: tail(output), - updatedAt: next.katagoBenchmarkUpdatedAt + updatedAt: next.katagoBenchmarkUpdatedAt, + engineFingerprint: fingerprint + } +} + +export async function benchmarkKataGo(request: KataGoBenchmarkRequest = {}): Promise { + return executeBenchmark(request) +} + +export function startKataGoBenchmark( + request: KataGoBenchmarkStartRequest = {}, + notify: (progress: KataGoBenchmarkProgress) => void +): KataGoBenchmarkStartResult { + const settings = getSettings() + const fingerprint = kataGoBenchmarkFingerprint(settings) + if (request.onlyIfNeeded && settings.katagoBenchmarkVisitsPerSecond > 0 && settings.katagoBenchmarkEngineFingerprint === fingerprint) { + return { runId: '', status: 'skipped', message: '当前引擎已有有效测速结果。' } + } + if (activeBenchmark) { + if (request.automatic) { + return { runId: activeBenchmark.runId, status: 'running', message: '测速已在后台运行。' } + } + cancelKataGoBenchmark({ runId: activeBenchmark.runId }) + } + + const runId = randomUUID() + const controller = new AbortController() + const active: ActiveBenchmark = { runId, child: null, controller, notify } + activeBenchmark = active + notify({ runId, status: 'running', message: request.automatic ? '正在后台优化分析速度…' : '正在测速并优化…' }) + const effectiveRequest: KataGoBenchmarkRequest = request.automatic + ? { ...request, visits: request.visits ?? 48, numPositions: request.numPositions ?? 1, secondsPerMove: request.secondsPerMove ?? 2, timeoutMs: request.timeoutMs ?? 30_000 } + : request + void executeBenchmark(effectiveRequest, controller.signal, (child) => { + if (activeBenchmark?.runId === runId) activeBenchmark.child = child + }).then((result) => { + notify({ runId, status: 'completed', message: `分析速度已优化为 ${result.recommendedThreads} 个搜索线程。`, result }) + }).catch((error) => { + const status = error instanceof BenchmarkCancelledError + ? 'cancelled' + : error instanceof BenchmarkTimeoutError + ? 'timed-out' + : 'failed' + const message = status === 'cancelled' + ? '测速已取消,继续使用均衡设置。' + : status === 'timed-out' + ? '测速未在 30 秒内完成,继续使用均衡设置。' + : '测速没有完成,继续使用均衡设置。' + notify({ runId, status, message }) + }).finally(() => { + if (activeBenchmark?.runId === runId) activeBenchmark = null + }) + return { runId, status: 'running', message: request.automatic ? '测速已在后台开始。' : '测速已开始。' } +} + +export function cancelKataGoBenchmark(request: KataGoBenchmarkCancelRequest = {}): KataGoBenchmarkCancelResult { + if (!activeBenchmark || (request.runId && request.runId !== activeBenchmark.runId)) { + return { cancelled: false } } + const runId = activeBenchmark.runId + activeBenchmark.controller.abort() + activeBenchmark.child?.kill() + return { cancelled: true, runId } } diff --git a/src/main/services/llm.ts b/src/main/services/llm.ts index cb932df..7cadea4 100644 --- a/src/main/services/llm.ts +++ b/src/main/services/llm.ts @@ -1,5 +1,5 @@ import type { AppSettings, LlmModelsListRequest, LlmModelsListResult, LlmSettingsTestRequest, LlmSettingsTestResult } from '@main/lib/types' -import { getSettings } from '@main/lib/store' +import { getSettings, setSettings } from '@main/lib/store' import { listOpenAICompatibleModels, postOpenAICompatibleChat, probeOpenAICompatibleProvider, streamOpenAICompatibleChat } from './llm/openaiCompatibleProvider' import type { ChatMessage, ProviderSettings } from './llm/provider' @@ -72,9 +72,20 @@ export async function testLlmSettings(payload: LlmSettingsTestRequest): Promise< llmModel: payload.llmModel.trim() || saved.llmModel } const result = await probeOpenAICompatibleProvider(settings) + const capabilities = result.capabilities ?? { + text: { ok: result.ok, message: result.message, technicalDetail: result.technicalDetail }, + vision: { ok: Boolean(result.supportsImage), message: result.message, technicalDetail: result.technicalDetail }, + tools: { ok: false, message: '尚未验证工具调用。' } + } + const verifiedAt = result.ok ? new Date().toISOString() : '' + setSettings({ + llmSetupStatus: result.ok ? 'verified' : 'needs-attention', + llmLastVerifiedAt: verifiedAt + }) return { ok: result.ok, - message: result.technicalDetail ? `${result.message} ${result.technicalDetail}` : result.message + message: result.message, + capabilities } } diff --git a/src/main/services/llm/openaiCompatibleProvider.ts b/src/main/services/llm/openaiCompatibleProvider.ts index e3163e2..b396586 100644 --- a/src/main/services/llm/openaiCompatibleProvider.ts +++ b/src/main/services/llm/openaiCompatibleProvider.ts @@ -17,12 +17,13 @@ import type { ChatToolCall, ChatTurnResult, LlmProvider, + ProviderCapabilityCheck, ProviderProbeResult, ProviderSettings } from './provider' const tinyPng = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAeklEQVR42u3SMQ0AIAwAwcrBAQ7wLwEHTMxtsEFv+vmTi7Fm3rOza6Pz/GsQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQ8EELinI6hhXFUGQAAAAASUVORK5CYII=' + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAh0lEQVR4nO3YsQ3CQBAAQSzRBTW4PmqgPmqgjJfIyF/IGlu/E19wq8tuG2PcruyuF/hXAVoBWgFaAVoBWgFaAdp0wOP5PmKPn89rn5pf7wJnU4BWgFaAVoBWgFaAVoBWgFaAVoBWgFaAVoBWgLZewOz//mjrXeBsCtAK0ArQCtAK0ArQLh/wBab/CJDUGGJhAAAAAElFTkSuQmCC' function endpoint(settings: ProviderSettings): string { return `${settings.llmBaseUrl.replace(/\/$/, '')}/chat/completions` @@ -651,42 +652,120 @@ export async function streamOpenAICompatibleToolTurn( export async function probeOpenAICompatibleProvider(settings: ProviderSettings): Promise { if (!settings.llmBaseUrl.trim() || !settings.llmApiKey.trim() || !settings.llmModel.trim()) { + const missing = { ok: false, message: '尚未填写完整配置。' } return { ok: false, - message: 'Claude 兼容代理未配置。', - supportsImage: false + message: 'AI 模型尚未配置完整。', + supportsImage: false, + capabilities: { text: missing, vision: missing, tools: missing } } } - try { - const text = await postOpenAICompatibleChat(settings, [ + + const textCapability: ProviderCapabilityCheck = await (async () => { + try { + const text = await postOpenAICompatibleChat(settings, [ + { + role: 'system', + content: '这是连接测试。请在最终答案中只输出 OK。' + }, + { + role: 'user', + content: '回复 OK。' + } + ], 512) + return /^\s*OK[.!]?\s*$/i.test(text) + ? { ok: true, message: '文字回复正常。' } + : { ok: false, message: '模型返回了内容,但没有完成文字测试。', technicalDetail: text.slice(0, 180) } + } catch (error) { + return { ok: false, message: '文字连接失败。', technicalDetail: String(error) } + } + })() + + const visionCapability: ProviderCapabilityCheck = await (async () => { + if (!textCapability.ok) { + return { ok: false, message: '文字连接未通过,暂未测试图片。' } + } + try { + const text = await postOpenAICompatibleChat(settings, [ { role: 'system', content: [ '你正在执行多模态连接测试。', - '必须在最终可见 content 中只输出 OK 两个字母。', + '观察图片中央色块的颜色。', + '如果中央色块是蓝色,只输出 BLUE;如果是红色,只输出 RED;如果是绿色,只输出 GREEN。', '不要调用工具,不要输出解释,不要只写隐藏推理。' ].join('\n') }, { role: 'user', content: [ - { type: 'text', text: '请读取这张测试图片,并在最终答案中只输出 OK。' }, + { type: 'text', text: '请读取图片,并只输出中央色块对应的英文颜色词。' }, { type: 'image_url', image_url: { url: tinyPng, detail: 'high' } } ] } - ], 2048) - return { - ok: /ok/i.test(text), - message: /ok/i.test(text) ? 'Claude 兼容代理连接成功,图片输入可用。' : `代理有返回,但未按预期回答: ${text}`, - supportsImage: /ok/i.test(text) + ], 2048) + return /^\s*BLUE[.!]?\s*$/i.test(text) + ? { ok: true, message: '棋盘图片输入正常。' } + : { ok: false, message: '模型收到了请求,但没有完成图片测试。', technicalDetail: text.slice(0, 180) } + } catch (error) { + return { ok: false, message: '图片输入失败。', technicalDetail: String(error) } } - } catch (error) { - return { - ok: false, - message: 'Claude 兼容代理测试失败。', - supportsImage: false, - technicalDetail: String(error) + })() + + const toolsCapability: ProviderCapabilityCheck = await (async () => { + if (!textCapability.ok) { + return { ok: false, message: '文字连接未通过,暂未测试工具调用。' } } + try { + const result = await postOpenAICompatibleToolTurn(settings, [ + { + role: 'system', + content: '你正在执行工具能力测试。必须调用提供的 readiness_check 工具,不要直接回答。' + }, + { + role: 'user', + content: '请调用 readiness_check,并把 value 设为 goagent。' + } + ], [ + { + type: 'function', + function: { + name: 'readiness_check', + description: '确认模型可以发起工具调用。', + parameters: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + additionalProperties: false + } + } + } + ], 512) + const called = result.toolCalls.some((call) => { + if (call.function.name !== 'readiness_check') return false + try { + return JSON.parse(call.function.arguments).value === 'goagent' + } catch { + return false + } + }) + return called + ? { ok: true, message: 'Agent 工具调用正常。' } + : { ok: false, message: '模型可以回复,但没有按要求调用工具。', technicalDetail: result.text.slice(0, 180) } + } catch (error) { + return { ok: false, message: 'Agent 工具调用失败。', technicalDetail: String(error) } + } + })() + + const capabilities = { text: textCapability, vision: visionCapability, tools: toolsCapability } + const ok = Object.values(capabilities).every((item) => item.ok) + const failed = Object.values(capabilities).filter((item) => !item.ok) + return { + ok, + message: ok ? 'AI 老师已就绪:文字、棋盘图片和工具调用均可用。' : `AI 老师还需要处理 ${failed.length} 项能力。`, + supportsImage: visionCapability.ok, + technicalDetail: failed.map((item) => item.technicalDetail).filter(Boolean).join('\n') || undefined, + capabilities } } diff --git a/src/main/services/llm/provider.ts b/src/main/services/llm/provider.ts index e81fbd8..f62290d 100644 --- a/src/main/services/llm/provider.ts +++ b/src/main/services/llm/provider.ts @@ -66,6 +66,17 @@ export interface ProviderProbeResult { message: string supportsImage?: boolean technicalDetail?: string + capabilities?: { + text: ProviderCapabilityCheck + vision: ProviderCapabilityCheck + tools: ProviderCapabilityCheck + } +} + +export interface ProviderCapabilityCheck { + ok: boolean + message: string + technicalDetail?: string } export interface LlmProvider { diff --git a/src/main/services/teacherAgent.ts b/src/main/services/teacherAgent.ts index 22509c4..4fa783d 100644 --- a/src/main/services/teacherAgent.ts +++ b/src/main/services/teacherAgent.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { getGames, getSettings, replaceSettings, reportsDir } from '@main/lib/store' +import { getGames, getSettings, replaceSettings, reportsDir, setSettings } from '@main/lib/store' import type { AgentToolImageResult, BoardImageCaptureSelection, @@ -29,7 +29,7 @@ import type { VisionEvidenceImageRole, VisionEvidenceReport } from '@main/lib/types' -import type { ChatContentPart, ChatMessage, ChatTool, ChatToolCall, ProviderSettings } from './llm/provider' +import type { ChatContentPart, ChatMessage, ChatTool, ChatToolCall, ChatTurnResult, ProviderSettings } from './llm/provider' import { analyzeGameQuick, analyzePosition, cancelKataGoAnalysis } from './katago' import { MOVE_RANGE_KEY_MOVE_LIMIT, MOVE_RANGE_MAX_MOVES, parseMoveRangeFromPrompt, validateMoveRange } from '@shared/moveRange' import { formatMoveRangeSummaryForPrompt, selectMoveNumbersForRangeRefine } from './teacher/moveRangeReview' @@ -2057,11 +2057,19 @@ async function runTeacherAgentSession( for (;;) { assertTeacherRunActive(context) let streamedThisTurn = '' - const result = await streamOpenAICompatibleToolTurn(settings, messages, tools, 4096, (delta) => { - streamedThisTurn += delta - emittedText += delta - emitAssistantDelta(context, delta) - }, context?.signal) + let result: ChatTurnResult + try { + result = await streamOpenAICompatibleToolTurn(settings, messages, tools, 4096, (delta) => { + streamedThisTurn += delta + emittedText += delta + emitAssistantDelta(context, delta) + }, context?.signal) + } catch (error) { + if (!isCancellationError(error)) { + setSettings({ llmSetupStatus: 'needs-attention', llmLastVerifiedAt: '' }) + } + throw error + } assertTeacherRunActive(context) if (result.toolCalls.length > 0) { messages.push({ @@ -2099,9 +2107,17 @@ async function runTeacherAgentSession( if (visionIssues.some((issue) => issue.severity === 'error')) { messages.push({ role: 'assistant', content: finalText }) messages.push({ role: 'user', content: `${buildVisionEvidenceRepairNote(visionIssues)}\n\n${formatVisionEvidenceForPrompt(finalVisionEvidence)}` }) - const repair = await streamOpenAICompatibleToolTurn(settings, messages, tools, 2048, (delta) => { - emitAssistantDelta(context, delta) - }, context?.signal) + let repair: ChatTurnResult + try { + repair = await streamOpenAICompatibleToolTurn(settings, messages, tools, 2048, (delta) => { + emitAssistantDelta(context, delta) + }, context?.signal) + } catch (error) { + if (!isCancellationError(error)) { + setSettings({ llmSetupStatus: 'needs-attention', llmLastVerifiedAt: '' }) + } + throw error + } if (repair.toolCalls.length > 0) { throw new Error('LLM 在棋盘图证据修正阶段继续请求工具,请重新发起本次分析。') } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8a9912e..a7b7e3a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,11 @@ import type { KataGoAssetStatus, KataGoBenchmarkRequest, KataGoBenchmarkResult, + KataGoBenchmarkStartRequest, + KataGoBenchmarkStartResult, + KataGoBenchmarkCancelRequest, + KataGoBenchmarkCancelResult, + KataGoBenchmarkProgress, KataGoCancelAnalysisRequest, KataGoCancelAnalysisResult, LibraryDeleteRequest, @@ -82,6 +87,13 @@ const api = { cancelKataGoAnalysis: (payload: KataGoCancelAnalysisRequest): Promise => ipcRenderer.invoke('katago:cancel-analysis', payload), getAnalysisSchedulerStats: (): Promise => ipcRenderer.invoke('analysis-scheduler:stats'), benchmarkKataGo: (payload?: KataGoBenchmarkRequest): Promise => ipcRenderer.invoke('katago:benchmark', payload ?? {}), + startKataGoBenchmark: (payload?: KataGoBenchmarkStartRequest): Promise => ipcRenderer.invoke('katago:benchmark-start', payload ?? {}), + cancelKataGoBenchmark: (payload?: KataGoBenchmarkCancelRequest): Promise => ipcRenderer.invoke('katago:benchmark-cancel', payload ?? {}), + onKataGoBenchmarkProgress: (handler: (payload: KataGoBenchmarkProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: KataGoBenchmarkProgress): void => handler(payload) + ipcRenderer.on('katago:benchmark-progress', listener) + return () => ipcRenderer.removeListener('katago:benchmark-progress', listener) + }, onAnalyzePositionProgress: (handler: (payload: AnalyzePositionProgress) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: AnalyzePositionProgress): void => handler(payload) ipcRenderer.on('katago:analyze-position-progress', listener) @@ -100,6 +112,7 @@ const api = { getDiagnostics: (): Promise => ipcRenderer.invoke('diagnostics:get'), inspectKataGoAssets: (): Promise => ipcRenderer.invoke('katago-assets:inspect'), installKataGoOfficialModel: (payload: KataGoAssetInstallRequest): Promise => ipcRenderer.invoke('katago-assets:install-official-model', payload), + cancelKataGoAssetInstall: (): Promise<{ cancelled: boolean }> => ipcRenderer.invoke('katago-assets:cancel-install'), onKataGoAssetInstallProgress: (handler: (payload: KataGoAssetInstallProgress) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: KataGoAssetInstallProgress): void => handler(payload) ipcRenderer.on('katago-assets:install-progress', listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 81818ba..ce5429e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -11,6 +11,7 @@ import type { KataGoAssetInstallProgress, KataGoAssetStatus, KataGoBenchmarkResult, + KataGoBenchmarkTaskStatus, KataGoAnalysisSpeedMode, KataGoEngineMode, KataGoMoveAnalysis, @@ -57,6 +58,7 @@ import { type TrialBranch } from './features/board/trialBranch' import { DiagnosticsGate } from './features/diagnostics/DiagnosticsGate' +import { FIRST_RUN_ONBOARDING_VERSION, FirstRunOnboarding } from './features/onboarding/FirstRunOnboarding' import { UiGallery } from './features/gallery/UiGallery' import { StudentBindingDialog } from './features/student/StudentBindingDialog' import { StudentRailCard } from './features/student/StudentRailCard' @@ -92,6 +94,9 @@ const emptyDashboard: DashboardData = { katagoBenchmarkThreads: 0, katagoBenchmarkVisitsPerSecond: 0, katagoBenchmarkUpdatedAt: '', + katagoBenchmarkEngineFingerprint: '', + katagoBenchmarkLastCompletedAt: '', + katagoAutoBenchmarkEnabled: true, katagoEngineMode: 'auto', katagoAnalysisSpeedMode: 'auto', localAnalysisDefaultApplied: true, @@ -113,6 +118,9 @@ const emptyDashboard: DashboardData = { llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', llmModel: 'gpt-5-mini', + onboardingVersion: 0, + llmSetupStatus: 'unconfigured', + llmLastVerifiedAt: '', reviewLanguage: 'zh-CN', defaultPlayerName: '', ttsEnabled: true, @@ -811,6 +819,7 @@ export function App(): ReactElement { } const [dashboard, setDashboard] = useState(emptyDashboard) + const [dashboardLoaded, setDashboardLoaded] = useState(false) const [selectedId, setSelectedId] = useState('') const [record, setRecord] = useState(null) const [moveNumber, setMoveNumber] = useState(0) @@ -844,6 +853,7 @@ export function App(): ReactElement { const [libraryCollapsed, setLibraryCollapsed] = useState(false) const [llmTestMessage, setLlmTestMessage] = useState('') const [katagoBenchmark, setKataGoBenchmark] = useState(null) + const [katagoBenchmarkStatus, setKataGoBenchmarkStatus] = useState('idle') const [katagoBenchmarkMessage, setKataGoBenchmarkMessage] = useState('') const [katagoInstallMessage, setKataGoInstallMessage] = useState('') const [katagoInstallProgress, setKataGoInstallProgress] = useState(null) @@ -866,10 +876,17 @@ export function App(): ReactElement { const activeTeacherRunRef = useRef(null) const selectedGameIdRef = useRef('') const selectedEvaluationCacheKeyRef = useRef('') + const katagoBenchmarkRunIdRef = useRef('') + const autoBenchmarkAttemptRef = useRef('') + const benchmarkManualCancelRef = useRef(false) useEffect(() => { document.title = BRAND_DISPLAY_NAME }, []) + useEffect(() => { + if (!dashboardLoaded || dashboard.settings.onboardingVersion < FIRST_RUN_ONBOARDING_VERSION) return + document.documentElement.lang = normalizeUiLocale(dashboard.settings.reviewLanguage) + }, [dashboardLoaded, dashboard.settings.onboardingVersion, dashboard.settings.reviewLanguage]) const evaluationCacheRef = useRef>({}) const evaluationPersistTimersRef = useRef>({}) const boardFlashNonceRef = useRef(0) @@ -936,6 +953,62 @@ export function App(): ReactElement { }) }, []) + useEffect(() => { + return window.goagent.onKataGoBenchmarkProgress((progress) => { + if (katagoBenchmarkRunIdRef.current && progress.runId !== katagoBenchmarkRunIdRef.current) return + katagoBenchmarkRunIdRef.current = progress.status === 'running' ? progress.runId : '' + setKataGoBenchmarkStatus(progress.status) + const localizedMessage = progress.status === 'running' + ? t('benchmarkStartedMessage') + : progress.status === 'completed' + ? t('benchmarkCompletedMessage', { threads: progress.result?.recommendedThreads ?? dashboard.settings.katagoBenchmarkThreads ?? 1 }) + : progress.status === 'cancelled' + ? t('benchmarkCancelledMessage') + : progress.status === 'timed-out' + ? t('benchmarkTimedOutMessage') + : progress.status === 'failed' + ? t('benchmarkFailedMessage') + : progress.message + setKataGoBenchmarkMessage(localizedMessage) + if (progress.status === 'cancelled') { + if (!benchmarkManualCancelRef.current) autoBenchmarkAttemptRef.current = '' + benchmarkManualCancelRef.current = false + } + if (progress.result) setKataGoBenchmark(progress.result) + if (progress.status === 'completed') { + void window.goagent.getDashboard().then(setDashboard).catch(() => undefined) + void refreshKataGoAssets() + } + }) + }, [dashboard.settings.katagoBenchmarkThreads, t]) + + useEffect(() => { + if (!dashboardLoaded || dashboard.settings.onboardingVersion < FIRST_RUN_ONBOARDING_VERSION) return + if (!dashboard.settings.katagoAutoBenchmarkEnabled || !(katagoAssets?.ready || dashboard.systemProfile.katagoReady)) return + if (busy || graphBusy || territoryBusy || liveAnalysis.running || katagoBenchmarkStatus === 'running') return + const attemptKey = `${dashboard.settings.katagoModelPreset}:${katagoAssets?.modelPath ?? dashboard.systemProfile.katagoModel}` + if (autoBenchmarkAttemptRef.current === attemptKey) return + autoBenchmarkAttemptRef.current = attemptKey + const timer = window.setTimeout(() => { + void startKataGoBenchmarkTask(true) + }, 700) + return () => window.clearTimeout(timer) + }, [ + dashboardLoaded, + dashboard.settings.onboardingVersion, + dashboard.settings.katagoAutoBenchmarkEnabled, + dashboard.settings.katagoModelPreset, + dashboard.systemProfile.katagoReady, + dashboard.systemProfile.katagoModel, + katagoAssets?.ready, + katagoAssets?.modelPath, + busy, + graphBusy, + territoryBusy, + liveAnalysis.running, + katagoBenchmarkStatus + ]) + useEffect(() => { return () => { for (const timer of Object.values(evaluationPersistTimersRef.current)) { @@ -1240,6 +1313,8 @@ export function App(): ReactElement { } } catch (cause) { setError(`初始化失败: ${String(cause)}`) + } finally { + setDashboardLoaded(true) } } @@ -1571,7 +1646,8 @@ export function App(): ReactElement { llmApiKey: String(formData.get('llmApiKey') ?? ''), llmModel: String(formData.get('llmModel') ?? '') }) - setLlmTestMessage(result.message) + setLlmTestMessage(result.ok ? `${t('settingsAiTitle')} · ${t('ready')}` : t('llmSetupRequired')) + setDashboard(await window.goagent.getDashboard()) } catch (cause) { setLlmTestMessage(uiError(cause, 'llm-test')) } finally { @@ -1579,35 +1655,43 @@ export function App(): ReactElement { } } - async function runKataGoBenchmark(): Promise { - setBusy('katago-benchmark') - setKataGoBenchmarkMessage(t('benchmarkStarting')) - setError('') + async function startKataGoBenchmarkTask(automatic = false): Promise { + if (katagoBenchmarkStatus === 'running') return + benchmarkManualCancelRef.current = false + setKataGoBenchmarkMessage(automatic ? t('benchmarkStartedMessage') : t('benchmarkStarting')) try { - if (typeof window.goagent.benchmarkKataGo !== 'function') { - throw new Error('测速服务尚未加载,请重启应用后再试。') - } - const result = await window.goagent.benchmarkKataGo() - setKataGoBenchmark(result) - setKataGoBenchmarkMessage(`已优化:推荐 ${result.recommendedThreads} 线程,${formatSearchSpeed(result.visitsPerSecond)}。`) - setDashboard(await window.goagent.getDashboard()) - void refreshKataGoAssets() - if (selectedGame && record) { - pauseLiveAnalysis('测速完成,准备使用新配置') - setAnalysis(null) - setEvaluations({}) - void warmupEvaluationGraph(selectedGame.id, moveNumber) - if (!userPausedLiveAnalysisRef.current) { - void startLiveAnalysis() - } + const started = await window.goagent.startKataGoBenchmark({ automatic, onlyIfNeeded: automatic }) + if (started.status === 'skipped') { + setKataGoBenchmarkStatus('idle') + setKataGoBenchmarkMessage(t('benchmarkAlreadyReadyMessage')) + return } + katagoBenchmarkRunIdRef.current = started.runId + setKataGoBenchmarkStatus('running') } catch (cause) { - setKataGoBenchmarkMessage(uiError(cause, 'katago-benchmark')) - } finally { - setBusy('') + setKataGoBenchmarkStatus('failed') + setKataGoBenchmarkMessage(t('benchmarkFailedMessage')) + } + } + + async function cancelKataGoBenchmarkTask(): Promise { + benchmarkManualCancelRef.current = true + const result = await window.goagent.cancelKataGoBenchmark({ runId: katagoBenchmarkRunIdRef.current || undefined }) + if (result.cancelled) { + setKataGoBenchmarkStatus('cancelled') + setKataGoBenchmarkMessage(t('benchmarkCancelledMessage')) + } else { + benchmarkManualCancelRef.current = false } } + async function setAutoBenchmarkEnabled(enabled: boolean): Promise { + if (!enabled && katagoBenchmarkStatus === 'running') await cancelKataGoBenchmarkTask() + const next = await window.goagent.updateSettings({ katagoAutoBenchmarkEnabled: enabled }) + setDashboard(next) + if (enabled) autoBenchmarkAttemptRef.current = '' + } + async function installOfficialKataGoModel(presetId: KataGoModelPresetId): Promise { setBusy('katago-install') setError('') @@ -1629,6 +1713,18 @@ export function App(): ReactElement { } } } catch (cause) { + if (/KataGoAssetInstallPausedError|资源下载已暂停|資源下載已暫停/i.test(String(cause))) { + const message = '下载已暂停。再次点击即可从已下载的位置继续。' + setKataGoInstallMessage(message) + setKataGoInstallProgress((current) => ({ + stage: 'paused', + message, + receivedBytes: current?.receivedBytes, + totalBytes: current?.totalBytes, + percent: current?.percent + })) + return + } const message = uiError(cause, 'katago-install') setKataGoInstallMessage(message) setKataGoInstallProgress({ stage: 'error', message }) @@ -1637,6 +1733,13 @@ export function App(): ReactElement { } } + async function pauseKataGoAssetInstall(): Promise { + const result = await window.goagent.cancelKataGoAssetInstall() + if (result.cancelled) { + setKataGoInstallMessage('正在暂停,已下载的内容会保留。') + } + } + function appendMessage(message: Omit): string { const id = crypto.randomUUID() setMessages((current) => [...current, { ...message, id }]) @@ -1763,6 +1866,7 @@ export function App(): ReactElement { if (!isActiveTeacherRun(runId) || /老师任务已停止|KataGo 分析已取消|AbortError|已取消/i.test(String(cause))) { throw new Error('老师任务已停止') } + void window.goagent.getDashboard().then(setDashboard).catch(() => undefined) updateMessage(assistantMessageId, (message) => ({ ...message, status: 'error', @@ -2201,7 +2305,7 @@ export function App(): ReactElement { status: `已复用缓存 ${formatVisits(cachedVisits)}`, visits: cachedVisits, bestVisits: cachedBestVisits, - visitsPerSecond: dashboard.settings.katagoBenchmarkVisitsPerSecond, + visitsPerSecond: 0, targetMoveNumber: targetMove, round: 0 }) @@ -2636,7 +2740,16 @@ export function App(): ReactElement { } } + function ensureAiTeacherReady(): boolean { + const ready = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' + if (ready) return true + setLlmTestMessage(t('llmSetupRequired')) + setSettingsOpen(true) + return false + } + async function runMoveAnalysisAt(targetMoveNumber: number): Promise { + if (!ensureAiTeacherReady()) return if (!record || !selectedGame || busy !== '') { return } @@ -2721,6 +2834,7 @@ export function App(): ReactElement { } async function runTeacherQuickTask(text: string): Promise { + if (!ensureAiTeacherReady()) return if (busy !== '') { return } @@ -2803,6 +2917,7 @@ export function App(): ReactElement { if (!text || busy !== '') { return } + if (!ensureAiTeacherReady()) return setPrompt('') appendMessage({ role: 'student', content: text }) const assistantMessageId = appendMessage({ role: 'teacher', content: '', status: 'running', toolLogs: [] }) @@ -2910,6 +3025,7 @@ export function App(): ReactElement { await submitTeacherPromptText(prompt) } + const llmReady = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' const statusItems: StatusPill[] = [ { label: localizeKataGoStatus( @@ -2921,15 +3037,29 @@ export function App(): ReactElement { tone: dashboard.systemProfile.katagoReady ? 'good' : 'warn' }, { - label: dashboard.systemProfile.hasLlmApiKey ? t('llmReady') : t('llmMissing'), - tone: dashboard.systemProfile.hasLlmApiKey ? 'good' : 'warn' + label: llmReady ? t('llmReady') : t('llmMissing'), + tone: llmReady ? 'good' : 'warn' } ] - const liveAnalysisDisabled = busy === 'katago-install' || busy === 'katago-benchmark' + const liveAnalysisDisabled = busy === 'katago-install' const timelineFinalRecordScore = record && moveNumber === record.moves.length ? gameResultLeadForUi(record.game, t) : null + const onboarding = dashboardLoaded && dashboard.settings.onboardingVersion < FIRST_RUN_ONBOARDING_VERSION + ? ( + void installOfficialKataGoModel(dashboard.settings.katagoModelPreset)} + onPauseInstall={() => void pauseKataGoAssetInstall()} + /> + ) + : undefined return ( - +
@@ -3083,6 +3213,7 @@ export function App(): ReactElement { prompt={prompt} busy={busy} dashboard={dashboard} + llmReady={llmReady} t={t} error={error} teacherSessions={teacherSessions} @@ -3103,6 +3234,10 @@ export function App(): ReactElement { onNewTeacherSession={() => void startNewTeacherSession()} onRestoreTeacherSession={(sessionId) => void restoreTeacherSessionById(sessionId)} onDeleteTeacherSession={(sessionId) => void deleteTeacherSessionById(sessionId)} + onConnectLlm={() => { + setLlmTestMessage(t('llmSetupRequired')) + setSettingsOpen(true) + }} />
@@ -3110,7 +3245,7 @@ export function App(): ReactElement { graphBusy={graphBusy} graphProgress={graphProgress} katagoReady={katagoAssets?.ready || dashboard.systemProfile.katagoReady} - llmReady={dashboard.systemProfile.hasLlmApiKey} + llmReady={llmReady} busy={busy} t={t} /> @@ -3130,6 +3265,7 @@ export function App(): ReactElement { busy={busy} llmTestMessage={llmTestMessage} katagoBenchmark={katagoBenchmark} + katagoBenchmarkStatus={katagoBenchmarkStatus} katagoBenchmarkMessage={katagoBenchmarkMessage} katagoInstallMessage={katagoInstallMessage} katagoInstallProgress={katagoInstallProgress} @@ -3137,8 +3273,11 @@ export function App(): ReactElement { onClose={() => setSettingsOpen(false)} onSave={(form) => void saveSettings(form)} onTest={(form) => void testLlmSettings(form)} - onBenchmark={() => void runKataGoBenchmark()} + onBenchmark={() => void startKataGoBenchmarkTask(false)} + onCancelBenchmark={() => void cancelKataGoBenchmarkTask()} + onAutoBenchmarkChange={(enabled) => void setAutoBenchmarkEnabled(enabled)} onInstallOfficialModel={(presetId) => void installOfficialKataGoModel(presetId)} + onPauseKataGoInstall={() => void pauseKataGoAssetInstall()} onRefreshKataGoAssets={() => void refreshKataGoAssets()} onDashboardUpdated={setDashboard} /> @@ -3346,8 +3485,8 @@ function DesktopStatusBar({ return (
{graphBusy ? t('winrateAnalyzing', { progress: graphProgress || t('timelineLoading') }) : t('winrateReady')} - {t('katagoEngine')} - {t('visionLlm')} + {katagoReady ? t('katagoEngine') : t('katagoMissing')} + {llmReady ? t('llmReady') : t('llmMissing')} {busy ? t('appStatusTask', { busy }) : t('appStatusReady')}
) @@ -3438,6 +3577,7 @@ function DesktopPreferencesModal({ busy, llmTestMessage, katagoBenchmark, + katagoBenchmarkStatus, katagoBenchmarkMessage, katagoInstallMessage, katagoInstallProgress, @@ -3445,7 +3585,10 @@ function DesktopPreferencesModal({ onSave, onTest, onBenchmark, + onCancelBenchmark, + onAutoBenchmarkChange, onInstallOfficialModel, + onPauseKataGoInstall, onRefreshKataGoAssets, onDashboardUpdated, t @@ -3456,6 +3599,7 @@ function DesktopPreferencesModal({ busy: string llmTestMessage: string katagoBenchmark: KataGoBenchmarkResult | null + katagoBenchmarkStatus: KataGoBenchmarkTaskStatus | 'idle' katagoBenchmarkMessage: string katagoInstallMessage: string katagoInstallProgress: KataGoAssetInstallProgress | null @@ -3463,7 +3607,10 @@ function DesktopPreferencesModal({ onSave: (form: HTMLFormElement) => void onTest: (form: HTMLFormElement) => void onBenchmark: () => void + onCancelBenchmark: () => void + onAutoBenchmarkChange: (enabled: boolean) => void onInstallOfficialModel: (presetId: KataGoModelPresetId) => void + onPauseKataGoInstall: () => void onRefreshKataGoAssets: () => void onDashboardUpdated: (dashboard: DashboardData) => void t: UiTranslator @@ -3472,7 +3619,7 @@ function DesktopPreferencesModal({ return null } const katagoReady = katagoAssets?.ready || dashboard.systemProfile.katagoReady - const llmReady = dashboard.systemProfile.hasLlmApiKey + const llmReady = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' return (
event.stopPropagation()}> @@ -3497,13 +3644,17 @@ function DesktopPreferencesModal({ busy={busy} llmTestMessage={llmTestMessage} katagoBenchmark={katagoBenchmark} + katagoBenchmarkStatus={katagoBenchmarkStatus} katagoBenchmarkMessage={katagoBenchmarkMessage} katagoInstallMessage={katagoInstallMessage} katagoInstallProgress={katagoInstallProgress} onSave={onSave} onTest={onTest} onBenchmark={onBenchmark} + onCancelBenchmark={onCancelBenchmark} + onAutoBenchmarkChange={onAutoBenchmarkChange} onInstallOfficialModel={onInstallOfficialModel} + onPauseKataGoInstall={onPauseKataGoInstall} onRefreshKataGoAssets={onRefreshKataGoAssets} onDashboardUpdated={onDashboardUpdated} t={t} @@ -3934,6 +4085,7 @@ function TeacherPanel({ prompt, busy, dashboard, + llmReady, t, error, teacherSessions, @@ -3953,12 +4105,14 @@ function TeacherPanel({ onAnalyzeMove, onNewTeacherSession, onRestoreTeacherSession, - onDeleteTeacherSession + onDeleteTeacherSession, + onConnectLlm }: { messages: ChatMessage[] prompt: string busy: string dashboard: DashboardData + llmReady: boolean t: UiTranslator error: string teacherSessions: TeacherSession[] @@ -3979,6 +4133,7 @@ function TeacherPanel({ onNewTeacherSession: () => void onRestoreTeacherSession: (sessionId: string) => void onDeleteTeacherSession: (sessionId: string) => void + onConnectLlm: () => void }): ReactElement { const [historyOpen, setHistoryOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) @@ -4363,11 +4518,21 @@ function TeacherPanel({ ) : null} + {!llmReady ? ( +
+
+ {t('connectAiTeacher')} +

{t('connectAiTeacherDetail')}

+
+ +
+ ) : null} +
- {messages.length === 0 && !hasRunningTask ? ( + {messages.length === 0 && !hasRunningTask && llmReady ? (
+ ) +} + +type SettingsPageId = 'general' | 'ai' | 'katago' | 'voice' | 'about' function SettingsDrawer({ dashboard, @@ -4447,13 +4629,17 @@ function SettingsDrawer({ t, llmTestMessage, katagoBenchmark, + katagoBenchmarkStatus, katagoBenchmarkMessage, katagoInstallMessage, katagoInstallProgress, onSave, onTest, onBenchmark, + onCancelBenchmark, + onAutoBenchmarkChange, onInstallOfficialModel, + onPauseKataGoInstall, onRefreshKataGoAssets, onDashboardUpdated }: { @@ -4463,13 +4649,17 @@ function SettingsDrawer({ t: UiTranslator llmTestMessage: string katagoBenchmark: KataGoBenchmarkResult | null + katagoBenchmarkStatus: KataGoBenchmarkTaskStatus | 'idle' katagoBenchmarkMessage: string katagoInstallMessage: string katagoInstallProgress: KataGoAssetInstallProgress | null onSave: (form: HTMLFormElement) => void onTest: (form: HTMLFormElement) => void onBenchmark: () => void + onCancelBenchmark: () => void + onAutoBenchmarkChange: (enabled: boolean) => void onInstallOfficialModel: (presetId: KataGoModelPresetId) => void + onPauseKataGoInstall: () => void onRefreshKataGoAssets: () => void onDashboardUpdated: (dashboard: DashboardData) => void }): ReactElement { @@ -4572,16 +4762,18 @@ function SettingsDrawer({ setRefreshedLlmModels(models) setLlmModelsFetched(true) if (!models.length) { - setSelectedLlmModel('') + setLlmModelRefreshMessage(`${t('noModelReturned')}。${t('modelPickerEmpty')}`) } else if (!models.includes(selectedLlmModel)) { const fallback = models.includes(dashboard.settings.llmModel) ? dashboard.settings.llmModel : models[0] setSelectedLlmModel(fallback) autoSave({ llmModel: fallback }, 0) } } - setLlmModelRefreshMessage(result.message) - } catch (cause) { - setLlmModelRefreshMessage(t('modelRefreshFailed', { error: String(cause) })) + if (result.models.length) { + setLlmModelRefreshMessage(`${t('refreshModels')} · ${result.models.length}`) + } + } catch { + setLlmModelRefreshMessage(t('modelPickerEmpty')) } finally { setLlmModelsRefreshing(false) } @@ -4804,7 +4996,7 @@ function SettingsDrawer({ const zhiziNeedsLogin = /Zhizi Cloud Login Required|智子云需登录|智子云未登录/i.test(zhiziRawStatus) const zhiziStatusLabel = zhiziReady ? '已启用' : zhiziEnabled && zhiziNeedsLogin ? '需登录' : zhiziEnabled ? '待检测' : '未启用' const zhiziStatusClassName = zhiziReady ? 'settings-status-chip is-ready' : zhiziNeedsLogin ? 'settings-status-chip is-warning' : 'settings-status-chip' - const llmReady = dashboard.systemProfile.hasLlmApiKey + const llmReady = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' const katagoReady = Boolean(katagoAssets?.ready || dashboard.systemProfile.katagoReady) const voiceReady = dashboard.settings.ttsEnabled const settingsPages: Array<{ @@ -4816,57 +5008,48 @@ function SettingsDrawer({ status: string statusClassName: string }> = [ + { + id: 'general', + icon: '语', + title: t('settingsGeneralTitle'), + subtitle: t('settingsGeneralSubtitle'), + summary: t('settingsGeneralSummary'), + status: t('ready'), + statusClassName: 'settings-status-chip is-ready' + }, { id: 'ai', icon: 'AI', - title: 'AI 模型', - subtitle: '大模型连接与选择', - summary: '连接用于看棋盘图、解释 KataGo 证据和回答问题的多模态模型。', + title: t('settingsAiTitle'), + subtitle: t('settingsAiSubtitle'), + summary: t('settingsAiSummary'), status: llmReady ? t('ready') : t('pendingConfig'), statusClassName: llmReady ? 'settings-status-chip is-ready' : 'settings-status-chip' }, { id: 'katago', icon: '棋', - title: 'KataGo 引擎', - subtitle: '本地权重、测速与分析', - summary: '管理本地分析引擎、官方权重下载和一键测速。', + title: t('settingsAnalysisTitle'), + subtitle: t('settingsAnalysisSubtitle'), + summary: t('settingsAnalysisSummary'), status: katagoReady ? t('ready') : t('pendingConfig'), statusClassName: katagoReady ? 'settings-status-chip is-ready' : 'settings-status-chip' }, - { - id: 'remote', - icon: '云', - title: '智子云算力', - subtitle: '远程 KataGo 算力', - summary: '默认使用本机分析。只有手动启用智子云时,当前局面才会发送到远程算力。', - status: zhiziStatusLabel, - statusClassName: zhiziStatusClassName - }, { id: 'voice', icon: '声', - title: '语音朗读', - subtitle: 'TTS、音色与播放', - summary: '选择离线或云端语音,让老师讲解可以自然播放。', + title: t('settingsVoiceTitle'), + subtitle: t('settingsVoiceSubtitle'), + summary: t('settingsVoiceSummary'), status: voiceReady ? t('ready') : t('pendingConfig'), statusClassName: voiceReady ? 'settings-status-chip is-ready' : 'settings-status-chip' }, - { - id: 'general', - icon: '语', - title: '棋谱与语言', - subtitle: '语言、保存与本地数据', - summary: '设置界面语言和复盘讲解语言,保留本地优先的使用方式。', - status: t('ready'), - statusClassName: 'settings-status-chip is-ready' - }, { id: 'about', icon: 'i', - title: '关于', - subtitle: '版本、开源与支持', - summary: '查看 GoAgent 的项目定位、开源信息和用户反馈入口。', + title: t('settingsAboutTitle'), + subtitle: t('settingsAboutSubtitle'), + summary: t('settingsAboutSummary'), status: 'GoAgent', statusClassName: 'settings-status-chip is-ready' } @@ -4875,7 +5058,7 @@ function SettingsDrawer({ return (
-
- {llmTestMessage ?
{llmTestMessage}
: null} + {llmTestMessage ? ( + llmTestMessage.includes('\n\n') + ? + :
{llmTestMessage}
+ ) : null}
- +
+