mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): safely upgrade customized default assistants
This commit is contained in:
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -361,6 +361,18 @@
|
||||
},
|
||||
"fallbackName": "アシスタント",
|
||||
"duplicateSuffix": "(コピー)",
|
||||
"upgrade": {
|
||||
"title": "デフォルトアシスタントを更新できます",
|
||||
"message": "現在のデフォルトアシスタントは古いテンプレートを元にしており、カスタマイズが含まれています。ChatLab は「{backupName}」として保存してから最新版へ切り替えます。",
|
||||
"backupName": "{name}(旧版バックアップ)",
|
||||
"skipHint": "スキップすると、テンプレートが次に更新されるまで表示されません。",
|
||||
"skip": "スキップ",
|
||||
"skipFailed": "更新をスキップできませんでした",
|
||||
"confirm": "バックアップして更新",
|
||||
"success": "デフォルトアシスタントを更新しました",
|
||||
"successDescription": "以前の設定を「{name}」として保存しました",
|
||||
"failed": "更新に失敗しました"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"createSuccess": "作成しました",
|
||||
|
||||
@@ -361,6 +361,18 @@
|
||||
},
|
||||
"fallbackName": "助手",
|
||||
"duplicateSuffix": "(副本)",
|
||||
"upgrade": {
|
||||
"title": "默认助手有新版本",
|
||||
"message": "当前默认助手基于旧版模板,并且包含你的自定义内容。ChatLab 会先将它保存为“{backupName}”,再切换到最新版本。",
|
||||
"backupName": "{name}(旧版备份)",
|
||||
"skipHint": "跳过后,当前模板版本将不再提醒。",
|
||||
"skip": "跳过",
|
||||
"skipFailed": "跳过失败",
|
||||
"confirm": "备份并升级",
|
||||
"success": "默认助手已升级",
|
||||
"successDescription": "旧配置已保存为“{name}”",
|
||||
"failed": "升级失败"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "加载失败",
|
||||
"createSuccess": "创建成功",
|
||||
|
||||
@@ -361,6 +361,18 @@
|
||||
},
|
||||
"fallbackName": "助手",
|
||||
"duplicateSuffix": "(副本)",
|
||||
"upgrade": {
|
||||
"title": "預設助手有新版本",
|
||||
"message": "目前的預設助手來自舊版範本,且包含自訂內容。ChatLab 會先將它儲存為「{backupName}」,再切換到最新版本。",
|
||||
"backupName": "{name}(舊版備份)",
|
||||
"skipHint": "跳過後,目前的範本版本將不再提醒。",
|
||||
"skip": "跳過",
|
||||
"skipFailed": "跳過失敗",
|
||||
"confirm": "備份並升級",
|
||||
"success": "預設助手已升級",
|
||||
"successDescription": "舊設定已儲存為「{name}」",
|
||||
"failed": "升級失敗"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "載入失敗",
|
||||
"createSuccess": "建立成功",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user