feat(ai): safely upgrade customized default assistants

This commit is contained in:
digua committed 2026-07-17 00:42:04 +08:00
1 parent 3dc8fb3157
commit c73d18208e
20 files changed
+694 -10

No files matched your search

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, it } from 'node:test'
import assert from 'node:assert/strict'
import Fastify, { type FastifyInstance } from 'fastify'
import type { AssistantManager } from '@openchatlab/node-runtime'
import type { AssistantUpgradeInfo } from '@openchatlab/shared-types'
import { registerAiAssistantRoutes } from './ai-assistants'
describe('AI assistant upgrade routes', () => {
let app: FastifyInstance
let statusAssistantId: string | null
let upgradeRequest: { id: string; backupName: string } | null
beforeEach(async () => {
statusAssistantId = null
upgradeRequest = null
const upgradeInfo: AssistantUpgradeInfo = {
assistantId: 'general_cn',
builtinId: 'general_cn',
name: '通用助手',
currentVersion: null,
latestVersion: 2,
}
const assistantManager = {
getAssistantUpgradeInfo(id: string) {
statusAssistantId = id
return id === 'general_cn' ? upgradeInfo : null
},
upgradeAssistantWithBackup(id: string, backupName: string) {
upgradeRequest = { id, backupName }
if (backupName === 'fail') return { success: false, error: 'Upgrade failed' }
return { success: true, backupId: 'custom_1' }
},
} as unknown as AssistantManager
app = Fastify()
registerAiAssistantRoutes(app, { assistantManager })
await app.ready()
})
afterEach(async () => {
await app.close()
})
it('returns upgrade information for the requested assistant', async () => {
const response = await app.inject({ method: 'GET', url: '/_web/ai/assistants/general_cn/upgrade-status' })
assert.equal(response.statusCode, 200)
assert.equal(statusAssistantId, 'general_cn')
assert.deepEqual(response.json(), {
assistantId: 'general_cn',
builtinId: 'general_cn',
name: '通用助手',
currentVersion: null,
latestVersion: 2,
})
})
it('forwards the localized backup name and maps failures to 400', async () => {
const success = await app.inject({
method: 'POST',
url: '/_web/ai/assistants/general_cn/upgrade',
payload: { backupName: '通用助手(旧版备份)' },
})
assert.equal(success.statusCode, 200)
assert.deepEqual(upgradeRequest, { id: 'general_cn', backupName: '通用助手(旧版备份)' })
assert.deepEqual(success.json(), { success: true, backupId: 'custom_1' })
const failure = await app.inject({
method: 'POST',
url: '/_web/ai/assistants/general_cn/upgrade',
payload: { backupName: 'fail' },
})
assert.equal(failure.statusCode, 400)
assert.deepEqual(failure.json(), { success: false, error: 'Upgrade failed' })
})
it('rejects an upgrade request without a backup name', async () => {
const response = await app.inject({
method: 'POST',
url: '/_web/ai/assistants/general_cn/upgrade',
payload: {},
})
assert.equal(response.statusCode, 400)
assert.deepEqual(response.json(), { success: false, error: 'backupName must be a string' })
assert.equal(upgradeRequest, null)
})
})
@@ -18,6 +18,10 @@ export function registerAiAssistantRoutes(server: FastifyInstance, ctx: AiAssist
return mgr.getAllAssistants()
})
server.get<{ Params: { id: string } }>('/_web/ai/assistants/:id/upgrade-status', async (request) => {
return mgr.getAssistantUpgradeInfo(request.params.id)
})
server.get<{ Params: { id: string } }>('/_web/ai/assistants/:id', async (request, reply) => {
const config = mgr.getAssistantConfig(request.params.id)
if (!config) return reply.code(404).send({ error: 'Not found' })
@@ -51,6 +55,18 @@ export function registerAiAssistantRoutes(server: FastifyInstance, ctx: AiAssist
return result
})
server.post<{
Params: { id: string }
Body: { backupName?: unknown }
}>('/_web/ai/assistants/:id/upgrade', async (request, reply) => {
if (typeof request.body?.backupName !== 'string') {
return reply.code(400).send({ success: false, error: 'backupName must be a string' })
}
const result = mgr.upgradeAssistantWithBackup(request.params.id, request.body.backupName)
if (!result.success) return reply.code(400).send(result)
return result
})
server.post<{ Body: { rawMd: string } }>('/_web/ai/assistants/import', async (request) => {
return mgr.importAssistantFromMd(request.body.rawMd)
})
@@ -36,6 +36,15 @@ presetQuestions:
---
你是一个通用助手。`
const SAMPLE_BUILTIN_V2 = `---
id: general_cn
name: 通用助手
builtinVersion: 2
presetQuestions:
- 你好
---
你是更自然的分析搭档。`
const hashContent = (content: string) => createHash('sha256').update(content).digest('hex')
function getConfigDigest(config: AssistantConfig): string {
@@ -74,6 +83,20 @@ function createManager(opts?: {
return { manager: new AssistantManager(deps), fs: memFs }
}
function createCustomizedLegacyUpgradeManager(): ReturnType<typeof createManager> {
const legacyConfig = parseAssistantFile(SAMPLE_BUILTIN, 'general_cn.md')!
const fs = createMemoryFs()
fs.files.set(
'/data/assistants/general_cn.md',
serializeAssistant({ ...legacyConfig, builtinId: 'general_cn', systemPrompt: '保留我的自定义提示词。' })
)
return createManager({
fs,
builtins: [{ id: 'general_cn', content: SAMPLE_BUILTIN_V2 }],
legacyBuiltinDigests: { general_cn: [getConfigDigest(legacyConfig)] },
})
}
describe('AssistantManager', () => {
let manager: AssistantManager
let memFs: ReturnType<typeof createMemoryFs>
@@ -150,6 +173,78 @@ presetQuestions:
assert.equal(ctx.manager.getAssistantConfig('general_cn')!.systemPrompt, '保留我的自定义提示词。')
})
it('reports an upgrade only for a customized outdated default assistant', () => {
const ctx = createCustomizedLegacyUpgradeManager()
ctx.manager.init()
assert.deepEqual(ctx.manager.getAssistantUpgradeInfo('general_cn'), {
assistantId: 'general_cn',
builtinId: 'general_cn',
name: '通用助手',
currentVersion: null,
latestVersion: 2,
})
manager.init()
manager.updateAssistant('general_cn', { systemPrompt: '当前模板上的自定义提示词。' })
assert.equal(manager.getAssistantUpgradeInfo('general_cn'), null)
})
it('backs up a customized outdated default before upgrading it', () => {
const ctx = createCustomizedLegacyUpgradeManager()
ctx.manager.init()
const result = ctx.manager.upgradeAssistantWithBackup('general_cn', '通用助手(旧版备份)')
assert.deepEqual(result, { success: true, backupId: 'custom_1' })
assert.equal(ctx.manager.getAssistantConfig('general_cn')!.systemPrompt, '你是更自然的分析搭档。')
assert.equal(ctx.manager.getAssistantConfig('general_cn')!.builtinVersion, 2)
const backup = ctx.manager.getAssistantConfig('custom_1')!
assert.equal(backup.name, '通用助手(旧版备份)')
assert.equal(backup.systemPrompt, '保留我的自定义提示词。')
assert.equal(backup.builtinId, undefined)
assert.equal(backup.builtinVersion, undefined)
assert.equal(backup.builtinDigest, undefined)
assert.equal(ctx.manager.getAssistantUpgradeInfo('general_cn'), null)
})
it('keeps the old default untouched when its backup cannot be written', () => {
const ctx = createCustomizedLegacyUpgradeManager()
ctx.manager.init()
const writeFile = ctx.fs.writeFile
ctx.fs.writeFile = (filePath, content) => {
if (filePath.endsWith('/custom_1.md')) throw new Error('Backup write failed')
writeFile(filePath, content)
}
const result = ctx.manager.upgradeAssistantWithBackup('general_cn', '通用助手(旧版备份)')
assert.equal(result.success, false)
assert.equal(ctx.manager.getAssistantConfig('general_cn')!.systemPrompt, '保留我的自定义提示词。')
assert.equal(ctx.manager.hasAssistant('custom_1'), false)
})
it('reuses the backup when replacing the default is retried', () => {
const ctx = createCustomizedLegacyUpgradeManager()
ctx.manager.init()
const writeFile = ctx.fs.writeFile
ctx.fs.writeFile = (filePath, content) => {
if (filePath.endsWith('/general_cn.md')) throw new Error('Default write failed')
writeFile(filePath, content)
}
const failed = ctx.manager.upgradeAssistantWithBackup('general_cn', '通用助手(旧版备份)')
assert.deepEqual(failed, { success: false, backupId: 'custom_1', error: 'Error: Default write failed' })
assert.equal(ctx.manager.getAssistantConfig('general_cn')!.systemPrompt, '保留我的自定义提示词。')
ctx.fs.writeFile = writeFile
const retried = ctx.manager.upgradeAssistantWithBackup('general_cn', '通用助手(旧版备份)')
assert.deepEqual(retried, { success: true, backupId: 'custom_1' })
assert.equal(ctx.manager.getAllAssistants().length, 2)
assert.equal(ctx.manager.getAssistantConfig('general_cn')!.systemPrompt, '你是更自然的分析搭档。')
})
it('uses the stored digest to preserve later customizations', () => {
manager.init()
manager.updateAssistant('general_cn', { systemPrompt: '用户修改后的提示词。' })
@@ -7,7 +7,7 @@
import { parseAssistantFile, serializeAssistant } from './assistant-parser'
import type { AssistantConfig, AssistantSummary } from './types'
import { GENERAL_ASSISTANT_IDS } from '@openchatlab/shared-types'
import type { BuiltinAssistantInfo } from '@openchatlab/shared-types'
import type { AssistantUpgradeInfo, AssistantUpgradeResult, BuiltinAssistantInfo } from '@openchatlab/shared-types'
// ==================== Result types ====================
@@ -178,6 +178,17 @@ export class AssistantManager {
}
private shouldUpgradeBuiltin(existing: AssistantConfig, builtin: AssistantConfig): boolean {
const comparison = this.compareBuiltin(existing, builtin)
return comparison.isUnmodified && comparison.isOutdated
}
private compareBuiltin(
existing: AssistantConfig,
builtin: AssistantConfig
): {
isUnmodified: boolean
isOutdated: boolean
} {
const builtinDigest = this.builtinDigestCache.get(builtin.id) ?? this.getConfigDigest(builtin)
const existingDigest = this.getConfigDigest(existing)
const knownBaseDigests = existing.builtinDigest
@@ -186,7 +197,7 @@ export class AssistantManager {
const isUnmodified = existingDigest === builtinDigest || knownBaseDigests.includes(existingDigest)
const isOutdated = existing.builtinDigest !== builtinDigest || existing.builtinVersion !== builtin.builtinVersion
return isUnmodified && isOutdated
return { isUnmodified, isOutdated }
}
private loadAll(): void {
@@ -224,6 +235,28 @@ export class AssistantManager {
return this.cache.get(id) ?? null
}
getAssistantUpgradeInfo(id: string): AssistantUpgradeInfo | null {
this.ensureInitialized()
if (!this.generalIds.includes(id)) return null
const existing = this.cache.get(id)
if (!existing?.builtinId) return null
const builtin = this.getBuiltinConfig(existing.builtinId)
if (!builtin) return null
const comparison = this.compareBuiltin(existing, builtin)
if (!comparison.isOutdated || comparison.isUnmodified) return null
return {
assistantId: existing.id,
builtinId: existing.builtinId,
name: existing.name,
currentVersion: existing.builtinVersion ?? null,
latestVersion: builtin.builtinVersion ?? null,
}
}
hasAssistant(id: string): boolean {
this.ensureInitialized()
return this.cache.has(id)
@@ -335,6 +368,54 @@ export class AssistantManager {
return result
}
upgradeAssistantWithBackup(id: string, backupName: string): AssistantUpgradeResult {
this.ensureInitialized()
const upgradeInfo = this.getAssistantUpgradeInfo(id)
if (!upgradeInfo) return { success: false, error: `No assistant upgrade available: ${id}` }
const existing = this.cache.get(id)!
const builtin = this.getBuiltinConfig(upgradeInfo.builtinId)!
const normalizedBackupName = backupName.trim() || `${existing.name} (Legacy Backup)`
const backupConfig: AssistantConfig = {
...existing,
id: this.deps.generateId?.() || `custom_${Date.now().toString(36)}`,
name: normalizedBackupName,
builtinId: undefined,
builtinVersion: undefined,
builtinDigest: undefined,
}
const existingBackup = Array.from(this.cache.values()).find(
(config) => !config.builtinId && this.getConfigDigest(config) === this.getConfigDigest(backupConfig)
)
const backupId = existingBackup?.id ?? backupConfig.id
if (!existingBackup) {
const backupResult = this.saveToDisk(backupConfig)
if (!backupResult.success) return backupResult
}
const upgradeResult = this.saveToDisk(this.withBuiltinTracking(builtin, existing.id))
if (!upgradeResult.success) {
this.deps.logger?.warn('AssistantManager', 'Default assistant upgrade failed after backup', {
id: existing.id,
builtinId: existing.builtinId,
backupId,
})
return { ...upgradeResult, backupId }
}
this.deps.logger?.info('AssistantManager', 'Backed up and upgraded default assistant', {
id: existing.id,
builtinId: existing.builtinId,
backupId,
fromVersion: existing.builtinVersion,
toVersion: builtin.builtinVersion,
})
return { success: true, backupId }
}
// ==================== Internal ====================
private findByBuiltinId(builtinId: string): AssistantConfig | undefined {
+7 -1
View File
@@ -11,7 +11,13 @@ export { AiLogger, extractErrorInfo, extractErrorStack } from './ai-logger'
export { formatAIError } from './error-formatter'
export type { FormatAIErrorOptions } from './error-formatter'
export type { AssistantConfig, AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
export type {
AssistantConfig,
AssistantSummary,
AssistantUpgradeInfo,
AssistantUpgradeResult,
BuiltinAssistantInfo,
} from '@openchatlab/shared-types'
export type { SkillDef, SkillSummary } from './types'
export { parseAssistantFile, serializeAssistant } from './assistant-parser'
export {
+8 -1
View File
@@ -162,7 +162,14 @@ export {
} from './nlp'
// AI 助手/技能解析器 + 对话管理
export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './ai'
export type {
AssistantConfig,
AssistantSummary,
AssistantUpgradeInfo,
AssistantUpgradeResult,
SkillDef,
SkillSummary,
} from './ai'
export {
parseAssistantFile,
serializeAssistant,
@@ -181,6 +181,7 @@ test('loads thinkingLevels as empty object when field is absent in legacy prefer
const loaded = new PreferencesManager(systemDir).load()
assert.deepEqual(loaded.thinkingLevels, {})
assert.deepEqual(loaded.assistantUpgradeSkippedVersions, {})
// Existing fields must survive the migration
assert.equal(loaded.aiGlobalSettings.maxMessagesPerRequest, 500)
assert.equal(loaded.aiGlobalSettings.chartAutoMode, 'suggest')
@@ -189,6 +190,41 @@ test('loads thinkingLevels as empty object when field is absent in legacy prefer
}
})
test('persists and normalizes skipped assistant upgrade versions', () => {
const systemDir = createTempSystemDir()
try {
writeFileSync(
join(systemDir, 'preferences.json'),
JSON.stringify({
assistantUpgradeSkippedVersions: {
general_cn: 2,
invalid_zero: 0,
invalid_string: '3',
},
})
)
const manager = new PreferencesManager(systemDir)
assert.deepEqual(manager.load().assistantUpgradeSkippedVersions, { general_cn: 2 })
const result = manager.save({
assistantUpgradeSkippedVersions: {
general_cn: 2,
general_en: 3,
},
})
assert.equal(result.success, true)
manager.invalidateCache()
assert.deepEqual(manager.load().assistantUpgradeSkippedVersions, {
general_cn: 2,
general_en: 3,
})
} finally {
rmSync(systemDir, { recursive: true, force: true })
}
})
test('replaces built-in desensitize overrides with an empty map on save', () => {
const systemDir = createTempSystemDir()
try {
+13
View File
@@ -32,6 +32,7 @@ export type {
const DEFAULTS: Preferences = {
pinnedSessionIds: [],
assistantUpgradeSkippedVersions: {},
aiPreprocessConfig: {
dataCleaning: true,
mergeConsecutive: true,
@@ -136,6 +137,7 @@ export class PreferencesManager {
private mergeDefaults(partial: Partial<Preferences>): Preferences {
return {
pinnedSessionIds: partial.pinnedSessionIds ?? DEFAULTS.pinnedSessionIds,
assistantUpgradeSkippedVersions: this.normalizePositiveIntegerMap(partial.assistantUpgradeSkippedVersions),
aiPreprocessConfig: partial.aiPreprocessConfig
? this.normalizeAiPreprocessConfig(partial.aiPreprocessConfig)
: { ...DEFAULTS.aiPreprocessConfig },
@@ -186,6 +188,17 @@ export class PreferencesManager {
return result
}
private normalizePositiveIntegerMap(value: unknown): Record<string, number> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const result: Record<string, number> = {}
for (const [key, version] of Object.entries(value as Record<string, unknown>)) {
if (typeof version === 'number' && Number.isInteger(version) && version > 0) {
result[key] = version
}
}
return result
}
private migrateLegacyDesensitizeRules(
partial: Partial<Preferences>,
rawContent: string
+16
View File
@@ -131,6 +131,20 @@ export interface BuiltinAssistantInfo {
imported: boolean
}
export interface AssistantUpgradeInfo {
assistantId: string
builtinId: string
name: string
currentVersion: number | null
latestVersion: number | null
}
export interface AssistantUpgradeResult {
success: boolean
backupId?: string
error?: string
}
// ==================== 成员角色 ====================
export interface MemberRole {
@@ -626,6 +640,8 @@ export interface PeopleRelationshipsNeighborhoodResponse {
export interface Preferences {
pinnedSessionIds: string[]
/** Builtin assistant versions the user chose to skip, keyed by builtin assistant ID. */
assistantUpgradeSkippedVersions: Record<string, number>
aiPreprocessConfig: AIPreprocessConfig
aiGlobalSettings: AIGlobalSettings
customKeywordTemplates: KeywordTemplate[]
+80 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from '@/composables/useToast'
import ConversationList from './chat/ConversationList.vue'
@@ -13,6 +13,7 @@ import { useAIService, useLLMService } from '@/services'
import AssistantInlineBar from './assistant/AssistantInlineBar.vue'
import AssistantConfigModal from './assistant/AssistantConfigModal.vue'
import AssistantMarketModal from './assistant/AssistantMarketModal.vue'
import AssistantUpgradeModal from './assistant/AssistantUpgradeModal.vue'
import SkillMarketModal from './skill/SkillMarketModal.vue'
import SkillConfigModal from './skill/SkillConfigModal.vue'
import PresetQuestions from './input/PresetQuestions.vue'
@@ -24,6 +25,7 @@ import { useChatScroll } from './composables/useChatScroll'
import { useChatModals } from './composables/useChatModals'
import { groupMessagesToQAPairs } from './utils/chatMessages'
import type { MentionedMemberContext } from '@/composables/useAIChat'
import type { AssistantUpgradeInfo } from '@openchatlab/shared-types'
const { t } = useI18n()
const toast = useToast()
@@ -132,6 +134,15 @@ const chatInputRef = ref<{
fillInput: (content: string) => void
openSkillSelector: () => void
} | null>(null)
const assistantUpgradeInfo = ref<AssistantUpgradeInfo | null>(null)
const assistantUpgradeModalVisible = ref(false)
const isUpgradingAssistant = ref(false)
const isSkippingAssistantUpgrade = ref(false)
const assistantBackupName = computed(() => {
const name = assistantUpgradeInfo.value?.name || t('ai.assistant.fallbackName')
return t('ai.assistant.upgrade.backupName', { name })
})
// QA 对
const qaPairs = computed(() => groupMessagesToQAPairs(messages.value))
@@ -228,6 +239,59 @@ function handleSkillActivated() {
scrollToBottom(true)
}
async function checkAssistantUpgrade(): Promise<void> {
const info = await assistantStore.checkDefaultAssistantUpgrade(settingsStore.locale)
if (!info) return
assistantUpgradeInfo.value = info
assistantUpgradeModalVisible.value = true
}
async function handleAssistantUpgradeSkip(): Promise<void> {
const info = assistantUpgradeInfo.value
if (!info || isUpgradingAssistant.value || isSkippingAssistantUpgrade.value) return
isSkippingAssistantUpgrade.value = true
try {
const result = await assistantStore.skipAssistantUpgrade(info)
if (!result.success) {
toast.fail(t('ai.assistant.upgrade.skipFailed'), {
description: result.error || t('ai.assistant.toast.unknownError'),
})
return
}
assistantUpgradeModalVisible.value = false
assistantUpgradeInfo.value = null
} finally {
isSkippingAssistantUpgrade.value = false
}
}
async function handleAssistantUpgradeConfirm(): Promise<void> {
const info = assistantUpgradeInfo.value
if (!info || isUpgradingAssistant.value || isSkippingAssistantUpgrade.value) return
isUpgradingAssistant.value = true
try {
const backupName = assistantBackupName.value
const result = await assistantStore.upgradeAssistantWithBackup(info, backupName)
if (!result.success) {
toast.fail(t('ai.assistant.upgrade.failed'), {
description: result.error || t('ai.assistant.toast.unknownError'),
})
return
}
assistantUpgradeModalVisible.value = false
assistantUpgradeInfo.value = null
toast.success(t('ai.assistant.upgrade.success'), {
description: t('ai.assistant.upgrade.successDescription', { name: backupName }),
})
} finally {
isUpgradingAssistant.value = false
}
}
// 发送消息
async function handleSend(payload: { content: string; mentionedMembers: MentionedMemberContext[] }) {
const result = await sendMessage(payload.content, { mentionedMembers: payload.mentionedMembers })
@@ -308,6 +372,12 @@ function handleStop() {
// 初始化
checkLLMConfig()
updateMaxMessages()
onMounted(() => void checkAssistantUpgrade())
watch(
() => settingsStore.locale,
() => void checkAssistantUpgrade()
)
// 监听全局 AI 配置变化(从设置弹窗保存时触发)
watch(
@@ -550,6 +620,15 @@ watch(
@created="handleAssistantCreated"
/>
<AssistantUpgradeModal
:open="assistantUpgradeModalVisible"
:backup-name="assistantBackupName"
:upgrading="isUpgradingAssistant"
:skipping="isSkippingAssistantUpgrade"
@skip="handleAssistantUpgradeSkip"
@confirm="handleAssistantUpgradeConfirm"
/>
<!-- 助手管理弹窗 -->
<AssistantMarketModal
:open="marketModalVisible"
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps<{
open: boolean
backupName: string
upgrading: boolean
skipping: boolean
}>()
const emit = defineEmits<{
skip: []
confirm: []
}>()
const { t } = useI18n()
</script>
<template>
<UModal :open="open" :dismissible="false" :ui="{ content: 'sm:max-w-md z-50', overlay: 'z-40' }">
<template #content>
<div class="p-5">
<div class="flex items-start gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary-50 text-primary-600 dark:bg-primary-950/40 dark:text-primary-400"
>
<UIcon name="i-lucide-sparkles" class="h-5 w-5" />
</div>
<div class="min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ t('ai.assistant.upgrade.title') }}
</h3>
<p class="mt-1.5 text-sm leading-6 text-gray-600 dark:text-gray-400">
{{ t('ai.assistant.upgrade.message', { backupName }) }}
</p>
</div>
</div>
<div class="mt-4 rounded-lg bg-gray-50 px-3 py-2.5 dark:bg-gray-800/70">
<div class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<UIcon name="i-lucide-copy-check" class="h-4 w-4 shrink-0 text-gray-400" />
<span class="truncate">{{ backupName }}</span>
</div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('ai.assistant.upgrade.skipHint') }}
</p>
<div class="mt-5 flex justify-end gap-2">
<UButton variant="soft" :loading="skipping" :disabled="upgrading" @click="emit('skip')">
{{ t('ai.assistant.upgrade.skip') }}
</UButton>
<UButton color="primary" :loading="upgrading" :disabled="skipping" @click="emit('confirm')">
{{ t('ai.assistant.upgrade.confirm') }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
+12
View File
@@ -361,6 +361,18 @@
},
"fallbackName": "Assistant",
"duplicateSuffix": " (Copy)",
"upgrade": {
"title": "Default assistant update available",
"message": "Your current default assistant is based on an older template and contains custom changes. ChatLab will save it as “{backupName}” before switching to the latest version.",
"backupName": "{name} (Legacy Backup)",
"skipHint": "If skipped, ChatLab will not remind you again until the template is updated.",
"skip": "Skip",
"skipFailed": "Failed to skip update",
"confirm": "Back Up and Update",
"success": "Default assistant updated",
"successDescription": "The previous configuration was saved as “{name}”.",
"failed": "Update failed"
},
"toast": {
"loadFailed": "Failed to load",
"createSuccess": "Created successfully",
+12
View File
@@ -361,6 +361,18 @@
},
"fallbackName": "アシスタント",
"duplicateSuffix": "(コピー)",
"upgrade": {
"title": "デフォルトアシスタントを更新できます",
"message": "現在のデフォルトアシスタントは古いテンプレートを元にしており、カスタマイズが含まれています。ChatLab は「{backupName}」として保存してから最新版へ切り替えます。",
"backupName": "{name}(旧版バックアップ)",
"skipHint": "スキップすると、テンプレートが次に更新されるまで表示されません。",
"skip": "スキップ",
"skipFailed": "更新をスキップできませんでした",
"confirm": "バックアップして更新",
"success": "デフォルトアシスタントを更新しました",
"successDescription": "以前の設定を「{name}」として保存しました",
"failed": "更新に失敗しました"
},
"toast": {
"loadFailed": "読み込みに失敗しました",
"createSuccess": "作成しました",
+12
View File
@@ -361,6 +361,18 @@
},
"fallbackName": "助手",
"duplicateSuffix": "(副本)",
"upgrade": {
"title": "默认助手有新版本",
"message": "当前默认助手基于旧版模板,并且包含你的自定义内容。ChatLab 会先将它保存为“{backupName}”,再切换到最新版本。",
"backupName": "{name}(旧版备份)",
"skipHint": "跳过后,当前模板版本将不再提醒。",
"skip": "跳过",
"skipFailed": "跳过失败",
"confirm": "备份并升级",
"success": "默认助手已升级",
"successDescription": "旧配置已保存为“{name}”",
"failed": "升级失败"
},
"toast": {
"loadFailed": "加载失败",
"createSuccess": "创建成功",
+12
View File
@@ -361,6 +361,18 @@
},
"fallbackName": "助手",
"duplicateSuffix": "(副本)",
"upgrade": {
"title": "預設助手有新版本",
"message": "目前的預設助手來自舊版範本,且包含自訂內容。ChatLab 會先將它儲存為「{backupName}」,再切換到最新版本。",
"backupName": "{name}(舊版備份)",
"skipHint": "跳過後,目前的範本版本將不再提醒。",
"skip": "跳過",
"skipFailed": "跳過失敗",
"confirm": "備份並升級",
"success": "預設助手已升級",
"successDescription": "舊設定已儲存為「{name}」",
"failed": "升級失敗"
},
"toast": {
"loadFailed": "載入失敗",
"createSuccess": "建立成功",
+16 -1
View File
@@ -1,4 +1,11 @@
import type { AssistantServiceAdapter, AssistantSummary, AssistantConfig, BuiltinAssistantInfo } from './types'
import type {
AssistantServiceAdapter,
AssistantSummary,
AssistantConfig,
AssistantUpgradeInfo,
AssistantUpgradeResult,
BuiltinAssistantInfo,
} from './types'
import { get, post, put, del } from '../utils/http'
export class FetchAssistantAdapter implements AssistantServiceAdapter {
@@ -30,6 +37,14 @@ export class FetchAssistantAdapter implements AssistantServiceAdapter {
return post<{ success: boolean; error?: string }>(`/ai/assistants/${id}/reset`, {})
}
async getUpgradeInfo(id: string): Promise<AssistantUpgradeInfo | null> {
return get<AssistantUpgradeInfo | null>(`/ai/assistants/${id}/upgrade-status`)
}
async upgradeWithBackup(id: string, backupName: string): Promise<AssistantUpgradeResult> {
return post<AssistantUpgradeResult>(`/ai/assistants/${id}/upgrade`, { backupName })
}
async importFromMd(rawMd: string) {
return post<{ success: boolean; error?: string }>('/ai/assistants/import', { rawMd })
}
+16 -2
View File
@@ -1,6 +1,18 @@
import type { AssistantConfig, AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
import type {
AssistantConfig,
AssistantSummary,
AssistantUpgradeInfo,
AssistantUpgradeResult,
BuiltinAssistantInfo,
} from '@openchatlab/shared-types'
export type { AssistantConfig, AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
export type {
AssistantConfig,
AssistantSummary,
AssistantUpgradeInfo,
AssistantUpgradeResult,
BuiltinAssistantInfo,
} from '@openchatlab/shared-types'
export interface AssistantServiceAdapter {
getAll(): Promise<AssistantSummary[]>
@@ -9,6 +21,8 @@ export interface AssistantServiceAdapter {
update(id: string, updates: Partial<AssistantConfig>): Promise<{ success: boolean; error?: string }>
delete(id: string): Promise<{ success: boolean; error?: string }>
reset(id: string): Promise<{ success: boolean; error?: string }>
getUpgradeInfo(id: string): Promise<AssistantUpgradeInfo | null>
upgradeWithBackup(id: string, backupName: string): Promise<AssistantUpgradeResult>
importFromMd(rawMd: string): Promise<{ success: boolean; error?: string }>
importBuiltin(builtinId: string): Promise<{ success: boolean; error?: string }>
reimport(id: string): Promise<{ success: boolean; error?: string }>
+81 -2
View File
@@ -6,10 +6,18 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { i18n } from '@/i18n'
import { usePlatformService, useAssistantService } from '@/services'
import type { AssistantConfig, AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
import { usePlatformService, useAssistantService, usePreferencesService } from '@/services'
import {
getDefaultGeneralAssistantId,
type AssistantConfig,
type AssistantSummary,
type AssistantUpgradeInfo,
type AssistantUpgradeResult,
type BuiltinAssistantInfo,
} from '@openchatlab/shared-types'
import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale'
import { isAssistantUpgradeSkipped } from './assistantUpgradePreference'
const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE
const LOCALE_PATH_MAP: Record<string, string> = { 'zh-CN': 'cn', 'zh-TW': 'cn', 'en-US': 'en', 'ja-JP': 'ja' }
@@ -28,6 +36,8 @@ export const useAssistantStore = defineStore('assistant', () => {
const assistants = ref<AssistantSummary[]>([])
const selectedAssistantId = ref<string | null>(null)
const isLoaded = ref(false)
const checkedUpgradeAssistantIds = new Set<string>()
let assistantUpgradeSkippedVersions: Record<string, number> | null = null
/** @deprecated 本地内置目录已清空,保留兼容 */
const builtinCatalog = ref<BuiltinAssistantInfo[]>([])
@@ -206,6 +216,72 @@ export const useAssistantStore = defineStore('assistant', () => {
}
}
async function checkDefaultAssistantUpgrade(locale: string): Promise<AssistantUpgradeInfo | null> {
const assistantId = getDefaultGeneralAssistantId(locale)
if (checkedUpgradeAssistantIds.has(assistantId)) return null
// 同一默认助手每次启动只提示一次;请求失败时撤销标记,允许下次进入页面重试。
checkedUpgradeAssistantIds.add(assistantId)
try {
const info = await useAssistantService().getUpgradeInfo(assistantId)
if (!info) return null
try {
if (!assistantUpgradeSkippedVersions) {
const preferences = await usePreferencesService().getPreferences()
assistantUpgradeSkippedVersions = { ...preferences.assistantUpgradeSkippedVersions }
}
if (isAssistantUpgradeSkipped(assistantUpgradeSkippedVersions, info)) return null
} catch (error) {
// 偏好读取失败时仍展示升级提示,避免把真实可用的升级静默吞掉。
console.error('[AssistantStore] Failed to load skipped assistant upgrades:', error)
}
return info
} catch (error) {
checkedUpgradeAssistantIds.delete(assistantId)
console.error('[AssistantStore] Failed to check assistant upgrade:', error)
return null
}
}
async function skipAssistantUpgrade(info: AssistantUpgradeInfo): Promise<{ success: boolean; error?: string }> {
if (info.latestVersion === null) {
return { success: false, error: 'Assistant template version is unavailable' }
}
try {
if (!assistantUpgradeSkippedVersions) {
const preferences = await usePreferencesService().getPreferences()
assistantUpgradeSkippedVersions = { ...preferences.assistantUpgradeSkippedVersions }
}
const nextSkippedVersions = {
...assistantUpgradeSkippedVersions,
[info.builtinId]: info.latestVersion,
}
const result = await usePreferencesService().savePreferences({
assistantUpgradeSkippedVersions: nextSkippedVersions,
})
if (result.success) assistantUpgradeSkippedVersions = nextSkippedVersions
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function upgradeAssistantWithBackup(
info: AssistantUpgradeInfo,
backupName: string
): Promise<AssistantUpgradeResult> {
try {
const result = await useAssistantService().upgradeWithBackup(info.assistantId, backupName)
if (result.success) await loadAssistants()
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function importAssistant(builtinId: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await useAssistantService().importBuiltin(builtinId)
@@ -302,6 +378,9 @@ export const useAssistantStore = defineStore('assistant', () => {
createAssistant,
duplicateAssistant,
resetAssistant,
checkDefaultAssistantUpgrade,
skipAssistantUpgrade,
upgradeAssistantWithBackup,
importAssistant,
reimportAssistant,
deleteAssistant,
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AssistantUpgradeInfo } from '@openchatlab/shared-types'
import { isAssistantUpgradeSkipped } from './assistantUpgradePreference'
function createUpgradeInfo(latestVersion: number | null): AssistantUpgradeInfo {
return {
assistantId: 'general_cn',
builtinId: 'general_cn',
name: '通用分析助手',
currentVersion: 1,
latestVersion,
}
}
test('skips only the recorded builtin assistant version', () => {
const skippedVersions = { general_cn: 2 }
assert.equal(isAssistantUpgradeSkipped(skippedVersions, createUpgradeInfo(2)), true)
assert.equal(isAssistantUpgradeSkipped(skippedVersions, createUpgradeInfo(3)), false)
assert.equal(isAssistantUpgradeSkipped(skippedVersions, createUpgradeInfo(null)), false)
})
+8
View File
@@ -0,0 +1,8 @@
import type { AssistantUpgradeInfo } from '@openchatlab/shared-types'
export function isAssistantUpgradeSkipped(
skippedVersions: Readonly<Record<string, number>>,
info: AssistantUpgradeInfo
): boolean {
return info.latestVersion !== null && skippedVersions[info.builtinId] === info.latestVersion
}