feat: 新用户支持查看Demo示例

This commit is contained in:
digua
2026-05-06 21:04:09 +08:00
committed by digua
parent 50da84fe38
commit f181c70402
11 changed files with 307 additions and 5 deletions
+124
View File
@@ -0,0 +1,124 @@
/**
* Demo 示例数据下载与导入 IPC 处理器
*/
import { ipcMain, app } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import axios from 'axios'
import * as worker from '../worker/workerManager'
import type { IpcContext } from './types'
const DEMO_BASE_URL = 'https://chatlab.fun/assets/demo'
interface DemoProgress {
stage: 'downloading' | 'importing' | 'done' | 'error'
/** 当前处理的文件序号 (1-based) */
current: number
total: number
message?: string
}
function getDemoTempDir(): string {
const tempDir = path.join(app.getPath('userData'), 'temp', 'demo')
fs.mkdirSync(tempDir, { recursive: true })
return tempDir
}
async function downloadFile(url: string, destPath: string): Promise<void> {
const tmpPath = destPath + '.tmp'
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 60_000,
})
const buffer = Buffer.from(response.data)
if (buffer.length < 100) {
throw new Error(`Downloaded file too small (${buffer.length} bytes)`)
}
fs.writeFileSync(tmpPath, buffer)
fs.renameSync(tmpPath, destPath)
}
function cleanupDemoTemp(tempDir: string): void {
try {
if (fs.existsSync(tempDir)) {
for (const file of fs.readdirSync(tempDir)) {
fs.unlinkSync(path.join(tempDir, file))
}
fs.rmdirSync(tempDir)
}
} catch {
// best-effort cleanup
}
}
export function registerDemoHandlers(ctx: IpcContext): void {
const { win } = ctx
/**
* 下载并导入 Demo 示例数据
* 返回群聊和私聊的 sessionId
*/
ipcMain.handle(
'demo:downloadAndImport',
async (
_,
locale: string
): Promise<{
success: boolean
groupSessionId?: string
privateSessionId?: string
error?: string
}> => {
const tempDir = getDemoTempDir()
const groupPath = path.join(tempDir, 'demo-group.json')
const privatePath = path.join(tempDir, 'demo-private.json')
const sendProgress = (progress: DemoProgress) => {
win.webContents.send('demo:progress', progress)
}
try {
// Phase 1: Download
sendProgress({ stage: 'downloading', current: 1, total: 2 })
await downloadFile(`${DEMO_BASE_URL}/${locale}/demo-group.json`, groupPath)
sendProgress({ stage: 'downloading', current: 2, total: 2 })
await downloadFile(`${DEMO_BASE_URL}/${locale}/demo-private.json`, privatePath)
// Phase 2: Import group chat
sendProgress({ stage: 'importing', current: 1, total: 2 })
const groupResult = await worker.streamImport(groupPath)
if (!groupResult.success || !groupResult.sessionId) {
throw new Error(groupResult.error || 'Failed to import group demo')
}
// Phase 3: Import private chat
sendProgress({ stage: 'importing', current: 2, total: 2 })
const privateResult = await worker.streamImport(privatePath)
if (!privateResult.success || !privateResult.sessionId) {
throw new Error(privateResult.error || 'Failed to import private demo')
}
sendProgress({ stage: 'done', current: 2, total: 2 })
return {
success: true,
groupSessionId: groupResult.sessionId,
privateSessionId: privateResult.sessionId,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('[Demo] Download and import failed:', message)
sendProgress({ stage: 'error', current: 0, total: 2, message })
return { success: false, error: message }
} finally {
cleanupDemoTemp(tempDir)
}
}
)
}
+2
View File
@@ -16,6 +16,7 @@ import { registerNetworkHandlers } from './ipc/network'
import { registerNlpHandlers } from './ipc/nlp'
import { registerAnalyticsHandlers } from './analytics'
import { registerApiHandlers, initApiServer, cleanupApiServer } from './ipc/api'
import { registerDemoHandlers } from './ipc/demo'
// 导入 Worker 模块(用于异步分析查询和流式导入)
import * as worker from './worker/workerManager'
@@ -50,6 +51,7 @@ const mainIpcMain = (win: BrowserWindow) => {
registerNlpHandlers(context)
registerAnalyticsHandlers()
registerApiHandlers(context)
registerDemoHandlers(context)
// 启动 ChatLab API 服务(异步,不阻塞 IPC 注册)
initApiServer(context).catch((err) => {
+34
View File
@@ -483,6 +483,40 @@ export const chatApi = {
}> => {
return ipcRenderer.invoke('chat:cleanupTempExportFiles', filePaths)
},
// ==================== Demo 示例数据 ====================
/**
* 下载并导入 Demo 示例数据
*/
importDemo: (
locale: string
): Promise<{
success: boolean
groupSessionId?: string
privateSessionId?: string
error?: string
}> => {
return ipcRenderer.invoke('demo:downloadAndImport', locale)
},
/**
* 监听 Demo 导入进度
*/
onDemoProgress: (
callback: (progress: { stage: string; current: number; total: number; message?: string }) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
progress: { stage: string; current: number; total: number; message?: string }
) => {
callback(progress)
}
ipcRenderer.on('demo:progress', handler)
return () => {
ipcRenderer.removeListener('demo:progress', handler)
}
},
}
// Merge API - 合并功能
+11
View File
@@ -192,6 +192,17 @@ interface ChatApi {
success: boolean
error?: string
}>
importDemo: (locale: string) => Promise<{
success: boolean
groupSessionId?: string
privateSessionId?: string
error?: string
}>
onDemoProgress: (
callback: (progress: { stage: string; current: number; total: number; message?: string }) => void
) => () => void
}
interface Api {
@@ -127,6 +127,16 @@ async function saveTitle(convId: string) {
editingId.value = null
}
function handleMenuRename(conv: Conversation) {
menuOpenId.value = null
startEditing(conv)
}
function handleMenuDelete(convId: string) {
menuOpenId.value = null
handleDelete(convId)
}
// 删除对话
async function handleDelete(convId: string) {
if (props.disabled) return
@@ -283,7 +293,7 @@ defineExpose({
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
:disabled="disabled"
@click="menuOpenId = null; startEditing(conv)"
@click="handleMenuRename(conv)"
>
<UIcon name="i-heroicons-pencil" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.rename') }}
@@ -291,7 +301,7 @@ defineExpose({
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-red-500 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
:disabled="disabled"
@click="menuOpenId = null; handleDelete(conv.id)"
@click="handleMenuDelete(conv.id)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.delete') }}
+6
View File
@@ -76,6 +76,12 @@
"cancel": "Cancel merge"
}
},
"demo": {
"viewExample": "Try a Demo",
"downloading": "Downloading demo data...",
"importing": "Importing demo data...",
"failed": "Failed to load demo data. Please check your network and try again."
},
"migration": {
"title": "Database upgrade required",
"description": "{count} database(s) need to be upgraded for new features.",
+6
View File
@@ -76,6 +76,12 @@
"cancel": "結合をキャンセル"
}
},
"demo": {
"viewExample": "デモを試す",
"downloading": "デモデータをダウンロード中...",
"importing": "デモデータをインポート中...",
"failed": "デモデータの読み込みに失敗しました。ネットワーク接続を確認してから再試行してください。"
},
"migration": {
"title": "データベースのアップグレードが必要です",
"description": "新機能をサポートするため、{count} 件のデータベースをアップグレードする必要があります。",
+6
View File
@@ -76,6 +76,12 @@
"cancel": "取消合并"
}
},
"demo": {
"viewExample": "查看示例 Demo",
"downloading": "正在下载示例数据...",
"importing": "正在导入示例数据...",
"failed": "示例数据加载失败,请检查网络连接后重试"
},
"migration": {
"title": "数据库需要升级",
"description": "检测到 {count} 个数据库需要升级以支持新功能。",
+6
View File
@@ -76,6 +76,12 @@
"cancel": "取消合併"
}
},
"demo": {
"viewExample": "查看示例 Demo",
"downloading": "正在下載示例資料...",
"importing": "正在匯入示例資料...",
"failed": "示例資料載入失敗,請確認網路連線後重試"
},
"migration": {
"title": "資料庫需要更新",
"description": "偵測到 {count} 個資料庫需要升級以支援新功能。",
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useSessionStore } from '@/stores/session'
import { useSettingsStore } from '@/stores/settings'
import { getChatlabSiteLocalePath } from '@/utils/chatlabSiteLocale'
const { t } = useI18n()
const router = useRouter()
const sessionStore = useSessionStore()
const settingsStore = useSettingsStore()
const isImporting = ref(false)
const stage = ref<'downloading' | 'importing'>('downloading')
const error = ref<string | null>(null)
async function navigateToSession(sessionId: string) {
const session = await window.chatApi.getSession(sessionId)
if (session) {
const routeName = session.type === 'private' ? 'private-chat' : 'group-chat'
router.push({ name: routeName, params: { id: sessionId } })
}
}
async function handleImport() {
isImporting.value = true
error.value = null
stage.value = 'downloading'
const unsubscribe = window.chatApi.onDemoProgress((progress) => {
if (progress.stage === 'downloading' || progress.stage === 'importing') {
stage.value = progress.stage
}
})
try {
const demoLocale = getChatlabSiteLocalePath(settingsStore.locale) || 'en'
const result = await window.chatApi.importDemo(demoLocale)
unsubscribe()
if (result.success && result.groupSessionId) {
await sessionStore.loadSessions()
sessionStore.selectSession(result.groupSessionId)
const savedThreshold = localStorage.getItem('sessionGapThreshold')
const gapThreshold = savedThreshold ? parseInt(savedThreshold, 10) : 1800
try {
await window.sessionApi.generate(result.groupSessionId, gapThreshold)
if (result.privateSessionId) {
await window.sessionApi.generate(result.privateSessionId, gapThreshold)
}
} catch (e) {
console.error('自动生成会话索引失败:', e)
}
await navigateToSession(result.groupSessionId)
} else {
error.value = result.error || t('home.demo.failed')
}
} catch (e) {
error.value = String(e)
} finally {
isImporting.value = false
}
}
</script>
<template>
<div class="flex flex-col items-center gap-2">
<UButton
:trailing-icon="isImporting ? undefined : 'i-heroicons-chevron-right-20-solid'"
:loading="isImporting"
:disabled="isImporting"
@click="handleImport"
>
{{
isImporting
? stage === 'downloading'
? t('home.demo.downloading')
: t('home.demo.importing')
: t('home.demo.viewExample')
}}
</UButton>
<p v-if="error" class="text-xs text-red-500 dark:text-red-400">
{{ error }}
</p>
</div>
</template>
+10 -3
View File
@@ -9,6 +9,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { getChatlabSiteLocalePath } from '@/utils/chatlabSiteLocale'
import { useSessionStore, type BatchFileInfo, type MergeFileInfo } from '@/stores/session'
import DemoImportButton from './DemoImportButton.vue'
const { t } = useI18n()
const sessionStore = useSessionStore()
@@ -62,6 +63,9 @@ const router = useRouter()
// 合并导入开关
const mergeImportEnabled = ref(false)
// 是否展示 Demo 按钮(仅无任何会话时)
const showDemoButton = computed(() => sessionStore.sessions.length !== 0)
// 计算是否正在导入(单文件、批量或合并)
const isAnyImporting = computed(() => isImporting.value || isBatchImporting.value || isMergeImporting.value)
@@ -791,9 +795,12 @@ const getMergeFileProgressText = (file: MergeFileInfo) =>
</div>
</div>
<UButton :to="tutorialUrl" target="_blank" trailing-icon="i-heroicons-chevron-right-20-solid">
{{ t('home.import.tutorial') }}
</UButton>
<div class="flex items-center gap-3">
<DemoImportButton v-if="showDemoButton" />
<UButton :to="tutorialUrl" target="_blank" trailing-icon="i-heroicons-chevron-right-20-solid">
{{ t('home.import.tutorial') }}
</UButton>
</div>
<!-- 聊天选择器多聊天格式通用 -->
<ChatSelector v-model:open="showChatSelector" :file-path="chatSelectorFilePath" @select="handleChatSelect" />