From 9030d6b0c378b7eff97d35384a1c2dbf5ba69f72 Mon Sep 17 00:00:00 2001 From: digua Date: Wed, 3 Jun 2026 23:23:26 +0800 Subject: [PATCH] feat(update): add in-app update notice --- apps/cli/src/cli.ts | 21 +++- apps/cli/src/http/routes/web/index.ts | 30 +----- apps/cli/src/update-checker.test.ts | 26 +++++ apps/cli/src/update-checker.ts | 54 ++++++---- packages/core/src/index.ts | 4 + packages/core/src/version.test.ts | 22 ++++ packages/core/src/version.ts | 31 ++++++ src/components/common/Sidebar.vue | 144 ++++++++++++++++++++++++++ src/i18n/locales/en-US/layout.json | 9 ++ src/i18n/locales/en-US/settings.json | 2 +- src/i18n/locales/ja-JP/layout.json | 9 ++ src/i18n/locales/ja-JP/settings.json | 2 +- src/i18n/locales/zh-CN/layout.json | 9 ++ src/i18n/locales/zh-CN/settings.json | 2 +- src/i18n/locales/zh-TW/layout.json | 9 ++ src/i18n/locales/zh-TW/settings.json | 2 +- src/services/platform/web.ts | 2 +- 17 files changed, 328 insertions(+), 50 deletions(-) create mode 100644 apps/cli/src/update-checker.test.ts create mode 100644 packages/core/src/version.test.ts create mode 100644 packages/core/src/version.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index f6b840e2..62f1b568 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -31,11 +31,30 @@ const program = new Command() program.name('chatlab').description('ChatLab - Chat history analysis tool').version(getVersion(), '-v, --version') -program.hook('preAction', async () => { +program.hook('preAction', async (_thisCommand, actionCommand) => { + if (actionCommand.name() === 'update') return const { checkForUpdatesInteractive } = await import('./update-checker') await checkForUpdatesInteractive() }) +program + .command('update') + .description('Update ChatLab CLI to the latest version') + .action(async () => { + const { performCliSelfUpdate } = await import('./update-checker') + const result = await performCliSelfUpdate({ + write: (text) => process.stderr.write(text), + }) + + if (result.success) { + console.error(' Updated successfully. Please restart chatlab to use the new version.\n') + return + } + + console.error(` Update failed: ${result.error || 'unknown error'}\n`) + process.exitCode = 1 + }) + program .command('sessions') .description('List all imported chat sessions') diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index be21d33c..25371599 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -20,7 +20,7 @@ import * as os from 'os' import * as path from 'path' import type { FastifyInstance } from 'fastify' -import type { PathProvider } from '@openchatlab/core' +import { isNewerStableVersion, type PathProvider } from '@openchatlab/core' import type { DatabaseManager, AIConversationManager, @@ -55,27 +55,6 @@ export interface AiContextOptions { runAgentStream?: HttpRouteContext['runAgentStream'] } -/** - * Semver comparison: returns true if `latest` is strictly newer than `current`. - * Handles pre-release tags (e.g. 0.22.0-beta.1 < 0.22.0). - */ -function isNewerVersion(latest: string, current: string): boolean { - const parse = (v: string) => { - const [core, pre] = v.split('-', 2) - const parts = core.split('.').map(Number) - return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0, pre } - } - const l = parse(latest) - const c = parse(current) - if (l.major !== c.major) return l.major > c.major - if (l.minor !== c.minor) return l.minor > c.minor - if (l.patch !== c.patch) return l.patch > c.patch - // Same core version: stable > pre-release - // Design note: prerelease-to-prerelease updates are intentionally not surfaced. - if (c.pre && !l.pre) return true - return false -} - export function registerWebRoutes( server: FastifyInstance, dbManager: DatabaseManager, @@ -161,18 +140,17 @@ export function registerWebRoutes( server.get('/_web/system/check-update', async () => { const currentVersion = getVersion() - const packageName = 'chatlab-cli' try { - const resp = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { + const resp = await fetch('https://chatlab.fun/latest-version', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000), }) if (!resp.ok) { - return { hasUpdate: false, currentVersion, error: `npm registry HTTP ${resp.status}` } + return { hasUpdate: false, currentVersion, error: `latest-version HTTP ${resp.status}` } } const data = (await resp.json()) as { version?: string } const latestVersion = data.version || currentVersion - const hasUpdate = isNewerVersion(latestVersion, currentVersion) + const hasUpdate = isNewerStableVersion(latestVersion, currentVersion) return { hasUpdate, currentVersion, latestVersion } } catch (err) { return { diff --git a/apps/cli/src/update-checker.test.ts b/apps/cli/src/update-checker.test.ts new file mode 100644 index 00000000..8ef07b7f --- /dev/null +++ b/apps/cli/src/update-checker.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { performCliSelfUpdate } from './update-checker' + +describe('performCliSelfUpdate', () => { + it('installs the latest chatlab-cli package globally', async () => { + const calls: Array<{ command: string; args: string[] }> = [] + + const result = await performCliSelfUpdate({ + runCommand: async (command, args) => { + calls.push({ command, args }) + return { success: true } + }, + write: () => {}, + platform: 'darwin', + }) + + assert.deepEqual(calls, [ + { + command: 'npm', + args: ['install', '-g', 'chatlab-cli@latest'], + }, + ]) + assert.deepEqual(result, { success: true }) + }) +}) diff --git a/apps/cli/src/update-checker.ts b/apps/cli/src/update-checker.ts index 07399fee..d0f55376 100644 --- a/apps/cli/src/update-checker.ts +++ b/apps/cli/src/update-checker.ts @@ -23,6 +23,17 @@ interface UpdateCache { skippedVersion?: string } +export interface UpdateCommandResult { + success: boolean + error?: string +} + +export interface PerformCliSelfUpdateOptions { + runCommand?: (command: string, args: string[]) => Promise + write?: (text: string) => void + platform?: NodeJS.Platform +} + function readCache(): UpdateCache | null { try { if (fs.existsSync(CACHE_FILE)) { @@ -115,28 +126,35 @@ function promptUser(question: string, choices: string[]): Promise { }) } -function runNpmUpdate(): Promise<{ success: boolean; error?: string }> { +function runNpmUpdateCommand( + command: string, + args: string[], + write: (text: string) => void +): Promise { return new Promise((resolve) => { - const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm' - process.stderr.write(`\n Running: npm install -g ${PACKAGE_NAME}@latest\n\n`) - - const child = execFile( - npmCmd, - ['install', '-g', `${PACKAGE_NAME}@latest`], - { timeout: 120_000 }, - (err, _stdout, stderr) => { - if (err) { - resolve({ success: false, error: stderr || err.message }) - } else { - resolve({ success: true }) - } + const child = execFile(command, args, { timeout: 120_000 }, (err, _stdout, stderr) => { + if (err) { + resolve({ success: false, error: stderr || err.message }) + } else { + resolve({ success: true }) } - ) - child.stdout?.pipe(process.stderr) - child.stderr?.pipe(process.stderr) + }) + child.stdout?.on('data', (chunk) => write(String(chunk))) + child.stderr?.on('data', (chunk) => write(String(chunk))) }) } +export function performCliSelfUpdate(options: PerformCliSelfUpdateOptions = {}): Promise { + const platformName = options.platform ?? process.platform + const npmCmd = platformName === 'win32' ? 'npm.cmd' : 'npm' + const args = ['install', '-g', `${PACKAGE_NAME}@latest`] + const write = options.write ?? ((text) => process.stderr.write(text)) + const runCommand = options.runCommand ?? ((command, commandArgs) => runNpmUpdateCommand(command, commandArgs, write)) + + write(`\n Running: ${npmCmd} ${args.join(' ')}\n\n`) + return runCommand(npmCmd, args) +} + function isDevEnvironment(): boolean { if (process.env.CHATLAB_SKIP_UPDATE_CHECK) return true if (process.env.NODE_ENV === 'development') return true @@ -208,7 +226,7 @@ export async function checkForUpdatesInteractive(): Promise { ]) if (choice === 1) { - const result = await runNpmUpdate() + const result = await performCliSelfUpdate() if (result.success) { process.stderr.write( ` \x1b[32m🎉 Updated successfully! Please restart chatlab to use the new version.\x1b[0m\n\n` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f22bd38c..31b4b54d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -180,6 +180,10 @@ export type { TableSchema, } from './query' +// 版本比较 +export { isNewerStableVersion, isStableVersion } from './version' +export type { ParsedStableVersion } from './version' + // NLP(平台无关的类型、数据和工具函数) export { POS_TAG_DEFINITIONS, diff --git a/packages/core/src/version.test.ts b/packages/core/src/version.test.ts new file mode 100644 index 00000000..40e08c5b --- /dev/null +++ b/packages/core/src/version.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isNewerStableVersion, isStableVersion } from './version' + +describe('stable version comparison', () => { + it('accepts only stable semver versions', () => { + assert.equal(isStableVersion('0.24.0'), true) + assert.equal(isStableVersion('v0.24.0'), true) + assert.equal(isStableVersion('0.24.0-beta.1'), false) + assert.equal(isStableVersion('0.24.0-rc.1'), false) + assert.equal(isStableVersion('latest'), false) + }) + + it('reports newer stable versions only when both sides are stable', () => { + assert.equal(isNewerStableVersion('0.25.0', '0.24.0'), true) + assert.equal(isNewerStableVersion('0.24.1', '0.24.0'), true) + assert.equal(isNewerStableVersion('0.24.0', '0.24.0'), false) + assert.equal(isNewerStableVersion('0.23.9', '0.24.0'), false) + assert.equal(isNewerStableVersion('0.25.0-beta.1', '0.24.0'), false) + assert.equal(isNewerStableVersion('0.25.0', '0.25.0-beta.1'), false) + }) +}) diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts new file mode 100644 index 00000000..2761caae --- /dev/null +++ b/packages/core/src/version.ts @@ -0,0 +1,31 @@ +export interface ParsedStableVersion { + major: number + minor: number + patch: number +} + +function parseStableVersion(version: string): ParsedStableVersion | null { + const normalized = version.trim().replace(/^v/i, '') + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(normalized) + if (!match) return null + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + } +} + +export function isStableVersion(version: string): boolean { + return parseStableVersion(version) !== null +} + +export function isNewerStableVersion(latest: string, current: string): boolean { + const latestVersion = parseStableVersion(latest) + const currentVersion = parseStableVersion(current) + if (!latestVersion || !currentVersion) return false + + if (latestVersion.major !== currentVersion.major) return latestVersion.major > currentVersion.major + if (latestVersion.minor !== currentVersion.minor) return latestVersion.minor > currentVersion.minor + return latestVersion.patch > currentVersion.patch +} diff --git a/src/components/common/Sidebar.vue b/src/components/common/Sidebar.vue index 276c71b2..2d49f93d 100644 --- a/src/components/common/Sidebar.vue +++ b/src/components/common/Sidebar.vue @@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' +import { isNewerStableVersion } from '@openchatlab/core' import type { AnalysisSession } from '@/types/base' import SidebarButton from './sidebar/SidebarButton.vue' import SidebarFooter from './sidebar/SidebarFooter.vue' @@ -15,6 +16,9 @@ import { IS_ELECTRON } from '@/utils/platform' import logoSvg from '@/assets/images/logo.svg' const { t } = useI18n() +const LATEST_VERSION_URL = 'https://chatlab.fun/latest-version' +const UPDATE_CHECK_CACHE_KEY = 'chatlab:latest-version-check' +const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const sessionStore = useSessionStore() const layoutStore = useLayoutStore() @@ -39,6 +43,10 @@ const deleteTarget = ref(null) // 版本号 const version = ref('') +const latestVersion = ref('') +const hasUpdate = ref(false) +const showUpdateModal = ref(false) +const isStartingUpdate = ref(false) // 搜索相关状态 const showSearch = ref(false) @@ -74,6 +82,7 @@ onMounted(async () => { sessionStore.loadSessions() try { version.value = await usePlatformService().getVersion() + void checkUpdateNotice() } catch (e) { console.error('Failed to get version', e) } @@ -94,6 +103,96 @@ function handleImport() { router.push('/') } +function readUpdateCheckCache(): { lastCheckTime: number; latestVersion: string | null } | null { + try { + const raw = window.localStorage.getItem(UPDATE_CHECK_CACHE_KEY) + if (!raw) return null + const data = JSON.parse(raw) as { lastCheckTime?: unknown; latestVersion?: unknown } + if (typeof data.lastCheckTime !== 'number') return null + return { + lastCheckTime: data.lastCheckTime, + latestVersion: typeof data.latestVersion === 'string' ? data.latestVersion : null, + } + } catch { + return null + } +} + +function writeUpdateCheckCache(nextLatestVersion: string | null) { + try { + window.localStorage.setItem( + UPDATE_CHECK_CACHE_KEY, + JSON.stringify({ + lastCheckTime: Date.now(), + latestVersion: nextLatestVersion, + }) + ) + } catch { + // localStorage 不可用时忽略,更新提醒是非关键能力。 + } +} + +function setLatestVersion(nextLatestVersion: string | null) { + latestVersion.value = nextLatestVersion || '' + hasUpdate.value = Boolean(nextLatestVersion && isNewerStableVersion(nextLatestVersion, version.value)) +} + +function parseLatestVersionPayload(data: unknown): string | null { + if (!data || typeof data !== 'object') return null + const versionValue = (data as { version?: unknown }).version + return typeof versionValue === 'string' ? versionValue : null +} + +async function fetchLatestVersion(): Promise { + const platformService = usePlatformService() + if (IS_ELECTRON) { + const result = await platformService.fetchRemoteConfig(LATEST_VERSION_URL) + if (!result.success) return null + return parseLatestVersionPayload(result.data) + } + + const result = await platformService.checkUpdate() + if (!result || result.error) return null + return result.latestVersion || null +} + +async function checkUpdateNotice() { + const cache = readUpdateCheckCache() + if (cache) { + setLatestVersion(cache.latestVersion) + if (Date.now() - cache.lastCheckTime < UPDATE_CHECK_INTERVAL_MS) return + } + + try { + const nextLatestVersion = await fetchLatestVersion() + writeUpdateCheckCache(nextLatestVersion) + setLatestVersion(nextLatestVersion) + } catch (error) { + console.debug('Update notice check failed:', error) + } +} + +function openUpdateModal() { + showUpdateModal.value = true +} + +async function confirmUpdate() { + if (!IS_ELECTRON) { + showUpdateModal.value = false + return + } + + isStartingUpdate.value = true + try { + await usePlatformService().checkUpdate() + showUpdateModal.value = false + } finally { + setTimeout(() => { + isStartingUpdate.value = false + }, 3000) + } +} + // 打开重命名弹窗 function openRenameModal(session: AnalysisSession) { renameTarget.value = session @@ -253,6 +352,15 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) { {{ t('layout.brand') }} v{{ version }} +
+ + + + +
diff --git a/src/i18n/locales/en-US/layout.json b/src/i18n/locales/en-US/layout.json index 7fb6cfc4..dfdaead6 100644 --- a/src/i18n/locales/en-US/layout.json +++ b/src/i18n/locales/en-US/layout.json @@ -21,6 +21,15 @@ "title": "Confirm Delete", "message": "Are you sure you want to delete \"{name}\"? This action cannot be undone." }, + "updateNotice": { + "tag": "New", + "title": "Update Available", + "message": "The latest version is v{version}.", + "desktopHint": "Click Update Now to start checking and downloading the desktop update.", + "cliHint": "Run the following command in your terminal, then restart the service.", + "updateNow": "Update Now", + "later": "Later" + }, "filter": { "all": "All", "private": "Private", diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index acd07746..4e105460 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -393,7 +393,7 @@ "upToDate": "You are up to date", "newVersionAvailable": "New version {version} available", "updateNow": "Update Now", - "webUpdateHint": "Run npm install -g chatlab-cli@latest in your terminal, then restart the service.", + "webUpdateHint": "Run clb update in your terminal, then restart the service.", "updating": "Updating...", "updateSuccess": "Update successful, please restart the service to apply", "updateFailed": "Update failed: {error}", diff --git a/src/i18n/locales/ja-JP/layout.json b/src/i18n/locales/ja-JP/layout.json index fe0a2e8c..39e17e82 100644 --- a/src/i18n/locales/ja-JP/layout.json +++ b/src/i18n/locales/ja-JP/layout.json @@ -21,6 +21,15 @@ "title": "削除の確認", "message": "チャット記録「{name}」を削除しますか?この操作は取り消せません。" }, + "updateNotice": { + "tag": "New", + "title": "新しいバージョンがあります", + "message": "最新バージョンは v{version} です。", + "desktopHint": "今すぐ更新をクリックすると、デスクトップ版の更新確認とダウンロードを開始します。", + "cliHint": "ターミナルで次のコマンドを実行し、更新後にサービスを再起動してください。", + "updateNow": "今すぐ更新", + "later": "後で" + }, "filter": { "all": "すべて", "private": "個人", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 98b94109..8d0dc17f 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -393,7 +393,7 @@ "upToDate": "最新バージョンです", "newVersionAvailable": "新しいバージョン {version} があります", "updateNow": "今すぐ更新", - "webUpdateHint": "ターミナルで npm install -g chatlab-cli@latest を実行し、サービスを再起動してください。", + "webUpdateHint": "ターミナルで clb update を実行し、サービスを再起動してください。", "updating": "更新中...", "updateSuccess": "更新が完了しました。サービスを再起動してください", "updateFailed": "更新に失敗しました:{error}", diff --git a/src/i18n/locales/zh-CN/layout.json b/src/i18n/locales/zh-CN/layout.json index e733ee65..5cbb31b9 100644 --- a/src/i18n/locales/zh-CN/layout.json +++ b/src/i18n/locales/zh-CN/layout.json @@ -21,6 +21,15 @@ "title": "确认删除", "message": "确定要删除聊天记录 \"{name}\" 吗?此操作无法撤销。" }, + "updateNotice": { + "tag": "New", + "title": "发现新版本", + "message": "最新版本是 v{version}。", + "desktopHint": "点击立即更新后,ChatLab 会开始检查并下载桌面端更新。", + "cliHint": "请在终端运行以下命令更新 CLI,更新后重启服务。", + "updateNow": "立即更新", + "later": "稍后" + }, "filter": { "all": "全部", "private": "私聊", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index eeeafa37..8e63d962 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -393,7 +393,7 @@ "upToDate": "已是最新版本", "newVersionAvailable": "发现新版本 {version}", "updateNow": "立即更新", - "webUpdateHint": "请在终端运行 npm install -g chatlab-cli@latest 后重启服务。", + "webUpdateHint": "请在终端运行 clb update 后重启服务。", "updating": "更新中...", "updateSuccess": "更新成功,请重启服务以生效", "updateFailed": "更新失败:{error}", diff --git a/src/i18n/locales/zh-TW/layout.json b/src/i18n/locales/zh-TW/layout.json index 6cec2a88..290cfc53 100644 --- a/src/i18n/locales/zh-TW/layout.json +++ b/src/i18n/locales/zh-TW/layout.json @@ -21,6 +21,15 @@ "title": "確認刪除", "message": "確定要刪除聊天紀錄「{name}」嗎?此操作無法復原。" }, + "updateNotice": { + "tag": "New", + "title": "發現新版本", + "message": "最新版本是 v{version}。", + "desktopHint": "點擊立即更新後,ChatLab 會開始檢查並下載桌面端更新。", + "cliHint": "請在終端機執行以下命令更新 CLI,更新後重新啟動服務。", + "updateNow": "立即更新", + "later": "稍後" + }, "filter": { "all": "全部", "private": "私聊", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 4847cd73..82c69eac 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -393,7 +393,7 @@ "upToDate": "已是最新版本", "newVersionAvailable": "發現新版本 {version}", "updateNow": "立即更新", - "webUpdateHint": "請在終端機執行 npm install -g chatlab-cli@latest 後重啟服務。", + "webUpdateHint": "請在終端機執行 clb update 後重啟服務。", "updating": "更新中...", "updateSuccess": "更新成功,請重啟服務以生效", "updateFailed": "更新失敗:{error}", diff --git a/src/services/platform/web.ts b/src/services/platform/web.ts index 3658133d..c2a48a95 100644 --- a/src/services/platform/web.ts +++ b/src/services/platform/web.ts @@ -76,7 +76,7 @@ export class WebPlatformAdapter implements PlatformAdapter { async performUpdate(): Promise { return { success: false, - error: 'Web update is unavailable. Run npm install -g chatlab-cli@latest in your terminal.', + error: 'Web update is unavailable. Run clb update in your terminal.', } }