feat(update): add in-app update notice

This commit is contained in:
digua committed 2026-06-04 14:37:12 +08:00
1 parent 218c3c9ee9
commit 9030d6b0c3
17 files changed
+328 -50

No files matched your search

+20 -1
View File
@@ -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')
+4 -26
View File
@@ -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 {
+26
View File
@@ -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 })
})
})
+36 -18
View File
@@ -23,6 +23,17 @@ interface UpdateCache {
skippedVersion?: string
}
export interface UpdateCommandResult {
success: boolean
error?: string
}
export interface PerformCliSelfUpdateOptions {
runCommand?: (command: string, args: string[]) => Promise<UpdateCommandResult>
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<number> {
})
}
function runNpmUpdate(): Promise<{ success: boolean; error?: string }> {
function runNpmUpdateCommand(
command: string,
args: string[],
write: (text: string) => void
): Promise<UpdateCommandResult> {
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<UpdateCommandResult> {
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<void> {
])
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`
+4
View File
@@ -180,6 +180,10 @@ export type {
TableSchema,
} from './query'
// 版本比较
export { isNewerStableVersion, isStableVersion } from './version'
export type { ParsedStableVersion } from './version'
// NLP(平台无关的类型、数据和工具函数)
export {
POS_TAG_DEFINITIONS,
+22
View File
@@ -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)
})
})
+31
View File
@@ -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
}
+144
View File
@@ -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<AnalysisSession | null>(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<string | null> {
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') }}
</div>
<span class="ml-2 text-xs text-gray-400">v{{ version }}</span>
<button
v-if="hasUpdate"
type="button"
class="ml-1.5 rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white shadow-sm shadow-red-500/20 transition hover:bg-red-600"
style="-webkit-app-region: no-drag"
@click="openUpdateModal"
>
{{ t('layout.updateNotice.tag') }}
</button>
</div>
<div
v-else
@@ -460,6 +568,42 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
</template>
</UModal>
<!-- Update Notice Modal -->
<UModal v-model:open="showUpdateModal" :ui="{ content: 'z-50' }">
<template #content>
<div class="p-4">
<div class="mb-3 flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-red-50 text-red-500 dark:bg-red-950/30">
<UIcon name="i-lucide-sparkles" class="h-4 w-4" />
</div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ t('layout.updateNotice.title') }}
</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('layout.updateNotice.message', { version: latestVersion }) }}
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ IS_ELECTRON ? t('layout.updateNotice.desktopHint') : t('layout.updateNotice.cliHint') }}
</p>
<div
v-if="!IS_ELECTRON"
class="mt-3 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
>
clb update
</div>
<div class="mt-4 flex justify-end gap-2">
<UButton variant="soft" :disabled="isStartingUpdate" @click="showUpdateModal = false">
{{ t('layout.updateNotice.later') }}
</UButton>
<UButton v-if="IS_ELECTRON" color="primary" :loading="isStartingUpdate" @click="confirmUpdate">
{{ t('layout.updateNotice.updateNow') }}
</UButton>
</div>
</div>
</template>
</UModal>
<!-- Footer -->
<SidebarFooter />
</div>
+9
View File
@@ -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",
+1 -1
View File
@@ -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}",
+9
View File
@@ -21,6 +21,15 @@
"title": "削除の確認",
"message": "チャット記録「{name}」を削除しますか?この操作は取り消せません。"
},
"updateNotice": {
"tag": "New",
"title": "新しいバージョンがあります",
"message": "最新バージョンは v{version} です。",
"desktopHint": "今すぐ更新をクリックすると、デスクトップ版の更新確認とダウンロードを開始します。",
"cliHint": "ターミナルで次のコマンドを実行し、更新後にサービスを再起動してください。",
"updateNow": "今すぐ更新",
"later": "後で"
},
"filter": {
"all": "すべて",
"private": "個人",
+1 -1
View File
@@ -393,7 +393,7 @@
"upToDate": "最新バージョンです",
"newVersionAvailable": "新しいバージョン {version} があります",
"updateNow": "今すぐ更新",
"webUpdateHint": "ターミナルで npm install -g chatlab-cli@latest を実行し、サービスを再起動してください。",
"webUpdateHint": "ターミナルで clb update を実行し、サービスを再起動してください。",
"updating": "更新中...",
"updateSuccess": "更新が完了しました。サービスを再起動してください",
"updateFailed": "更新に失敗しました:{error}",
+9
View File
@@ -21,6 +21,15 @@
"title": "确认删除",
"message": "确定要删除聊天记录 \"{name}\" 吗?此操作无法撤销。"
},
"updateNotice": {
"tag": "New",
"title": "发现新版本",
"message": "最新版本是 v{version}。",
"desktopHint": "点击立即更新后,ChatLab 会开始检查并下载桌面端更新。",
"cliHint": "请在终端运行以下命令更新 CLI,更新后重启服务。",
"updateNow": "立即更新",
"later": "稍后"
},
"filter": {
"all": "全部",
"private": "私聊",
+1 -1
View File
@@ -393,7 +393,7 @@
"upToDate": "已是最新版本",
"newVersionAvailable": "发现新版本 {version}",
"updateNow": "立即更新",
"webUpdateHint": "请在终端运行 npm install -g chatlab-cli@latest 后重启服务。",
"webUpdateHint": "请在终端运行 clb update 后重启服务。",
"updating": "更新中...",
"updateSuccess": "更新成功,请重启服务以生效",
"updateFailed": "更新失败:{error}",
+9
View File
@@ -21,6 +21,15 @@
"title": "確認刪除",
"message": "確定要刪除聊天紀錄「{name}」嗎?此操作無法復原。"
},
"updateNotice": {
"tag": "New",
"title": "發現新版本",
"message": "最新版本是 v{version}。",
"desktopHint": "點擊立即更新後,ChatLab 會開始檢查並下載桌面端更新。",
"cliHint": "請在終端機執行以下命令更新 CLI,更新後重新啟動服務。",
"updateNow": "立即更新",
"later": "稍後"
},
"filter": {
"all": "全部",
"private": "私聊",
+1 -1
View File
@@ -393,7 +393,7 @@
"upToDate": "已是最新版本",
"newVersionAvailable": "發現新版本 {version}",
"updateNow": "立即更新",
"webUpdateHint": "請在終端機執行 npm install -g chatlab-cli@latest 後重啟服務。",
"webUpdateHint": "請在終端機執行 clb update 後重啟服務。",
"updating": "更新中...",
"updateSuccess": "更新成功,請重啟服務以生效",
"updateFailed": "更新失敗:{error}",
+1 -1
View File
@@ -76,7 +76,7 @@ export class WebPlatformAdapter implements PlatformAdapter {
async performUpdate(): Promise<PerformUpdateResult> {
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.',
}
}