mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-24 23:51:43 +08:00
feat: 新用户支持查看Demo示例
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 - 合并功能
|
||||
|
||||
Vendored
+11
@@ -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') }}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -76,6 +76,12 @@
|
||||
"cancel": "結合をキャンセル"
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"viewExample": "デモを試す",
|
||||
"downloading": "デモデータをダウンロード中...",
|
||||
"importing": "デモデータをインポート中...",
|
||||
"failed": "デモデータの読み込みに失敗しました。ネットワーク接続を確認してから再試行してください。"
|
||||
},
|
||||
"migration": {
|
||||
"title": "データベースのアップグレードが必要です",
|
||||
"description": "新機能をサポートするため、{count} 件のデータベースをアップグレードする必要があります。",
|
||||
|
||||
@@ -76,6 +76,12 @@
|
||||
"cancel": "取消合并"
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"viewExample": "查看示例 Demo",
|
||||
"downloading": "正在下载示例数据...",
|
||||
"importing": "正在导入示例数据...",
|
||||
"failed": "示例数据加载失败,请检查网络连接后重试"
|
||||
},
|
||||
"migration": {
|
||||
"title": "数据库需要升级",
|
||||
"description": "检测到 {count} 个数据库需要升级以支持新功能。",
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user