mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(web): add Web WASM application shell
This commit is contained in:
1 parent
ff6a397d4f
commit
5f92d953c0
29 files changed
+1012
-10
No files matched your search
@@ -100,3 +100,6 @@ jobs:
|
||||
|
||||
- name: Type check (node)
|
||||
run: pnpm run type-check:node
|
||||
|
||||
- name: Build Web WASM
|
||||
run: pnpm run build:web-wasm
|
||||
@@ -24,6 +24,7 @@ error-log-*.txt
|
||||
# Project
|
||||
dist/
|
||||
dist-web/
|
||||
dist-web-wasm/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
apps/cli/native/
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f7f5f2" />
|
||||
<meta
|
||||
name="description"
|
||||
content="ChatLab standalone browser edition — import and manage chat archives locally in your browser."
|
||||
/>
|
||||
<title>ChatLab Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Sidebar from '@/components/common/Sidebar.vue'
|
||||
import { initializeAppRuntime } from '@/bootstrap/app-initialization'
|
||||
import { initPreferencesSync } from '@/composables/usePreferencesSync'
|
||||
import { useBrowserRuntimeService } from '@/services/browser-runtime/service'
|
||||
import { initServices } from '@/services/registry'
|
||||
import { reportError } from '@/services/log-report'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const sessionStore = useSessionStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { isInitialized } = storeToRefs(sessionStore)
|
||||
const initError = ref<string | null>(null)
|
||||
const pageTransitionKey = computed(() => route.fullPath)
|
||||
|
||||
useColorMode({
|
||||
emitAuto: true,
|
||||
initialValue: 'light',
|
||||
})
|
||||
|
||||
const tooltip = { delayDuration: 100 }
|
||||
const toaster = {
|
||||
position: 'top-center' as const,
|
||||
progress: false,
|
||||
duration: 2000,
|
||||
}
|
||||
|
||||
let initInProgress = false
|
||||
|
||||
async function initializeApp() {
|
||||
if (initInProgress || isInitialized.value) return
|
||||
initInProgress = true
|
||||
initError.value = null
|
||||
|
||||
try {
|
||||
await initializeAppRuntime({
|
||||
capabilities: PLATFORM_CAPABILITIES,
|
||||
initializeServices: () => initServices(),
|
||||
checkBrowserCapabilities: () => useBrowserRuntimeService().checkCapabilities(),
|
||||
initializePreferences: () => initPreferencesSync(),
|
||||
initializeLocale: () => settingsStore.initLocale(),
|
||||
loadSessions: () => sessionStore.loadSessions({ throwOnError: true }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Web WASM application initialization failed', error)
|
||||
initError.value = error instanceof Error ? error.message : String(error)
|
||||
reportError(initError.value, error instanceof Error ? error.stack : undefined)
|
||||
} finally {
|
||||
initInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(initializeApp)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp :tooltip="tooltip" :toaster="toaster">
|
||||
<div class="relative flex h-screen w-full overflow-hidden bg-page-bg dark:bg-page-dark">
|
||||
<template v-if="!isInitialized">
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<div v-if="initError" class="flex flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-8 w-8 text-red-500" />
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ t('common.initFailed') }}</p>
|
||||
<p class="max-w-sm text-xs text-gray-500">{{ initError }}</p>
|
||||
<UButton size="sm" color="primary" variant="soft" @click="initializeApp">
|
||||
{{ t('common.retry') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center text-center">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
|
||||
<p class="mt-2 text-sm text-gray-500">{{ t('common.initializing') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Sidebar :backend-features="false" />
|
||||
<main class="relative flex-1 overflow-hidden">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition name="page-fade" mode="out-in">
|
||||
<component :is="Component" :key="pageTransitionKey" />
|
||||
</Transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-fade-enter-active,
|
||||
.page-fade-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.page-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.page-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import ui from '@nuxt/ui/vue-plugin'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import i18n from '@/i18n'
|
||||
import { backendPersistPlugin } from '@/plugins/backendPersist'
|
||||
import { useBrowserRuntimeService } from '@/services/browser-runtime/service'
|
||||
import { installGlobalErrorReporting, reportError, reportRuntimeLog } from '@/services/log-report'
|
||||
import { initServices } from '@/services/registry'
|
||||
import { registerWebWasmAdapters } from '@/services/browser-runtime/register'
|
||||
import { handleWebWasmPageHide } from './runtime-lifecycle'
|
||||
import '@/assets/styles/main.css'
|
||||
|
||||
async function start(): Promise<void> {
|
||||
reportRuntimeLog({ level: 'info', scope: 'web-wasm-bootstrap', message: 'Initializing Web WASM services' })
|
||||
installGlobalErrorReporting()
|
||||
await initServices({ initializeWebWasm: registerWebWasmAdapters })
|
||||
|
||||
const runtime = useBrowserRuntimeService()
|
||||
window.addEventListener('pagehide', (event) => handleWebWasmPageHide(event, () => runtime.dispose()))
|
||||
|
||||
const app = createApp(App)
|
||||
app.config.errorHandler = (error, _instance, info) => {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error))
|
||||
console.error(normalized, info)
|
||||
reportError(normalized.message, normalized.stack)
|
||||
}
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
pinia.use(backendPersistPlugin)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ui)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error('Web WASM startup failed', error)
|
||||
reportError(error instanceof Error ? error.message : String(error), error instanceof Error ? error.stack : undefined)
|
||||
const root = document.querySelector<HTMLElement>('#app')
|
||||
if (root) root.textContent = error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import logoSvg from '@/assets/images/logo.svg'
|
||||
import AgreementModal from '@/components/home/AgreementModal.vue'
|
||||
import ChangelogModal from '@/components/home/ChangelogModal.vue'
|
||||
import HomeFooter from '@/components/home/HomeFooter.vue'
|
||||
import ImportArea from '@/components/import/ImportArea.vue'
|
||||
import LanguageSelectModal from '@/components/home/LanguageSelectModal.vue'
|
||||
import { getChatlabSiteLocalePath } from '@/utils/chatlabSiteLocale'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const isMounted = ref(false)
|
||||
const changelogModalRef = ref<InstanceType<typeof ChangelogModal> | null>(null)
|
||||
const agreementModalRef = ref<InstanceType<typeof AgreementModal> | null>(null)
|
||||
|
||||
const tutorialExportUrl = computed(() => {
|
||||
const localePath = getChatlabSiteLocalePath(locale.value)
|
||||
const langPath = localePath === 'cn' || localePath === 'tw' ? `/${localePath}/` : '/'
|
||||
return `https://docs.chatlab.fun${langPath}`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// Web WASM 会在异步初始化完成后才挂载首页,多等待一帧,确保初始透明态先完成绘制。
|
||||
requestAnimationFrame(() => {
|
||||
isMounted.value = true
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex h-full w-full overflow-hidden pt-4">
|
||||
<div class="relative h-full w-full overflow-y-auto">
|
||||
<div class="flex min-h-full w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div
|
||||
class="relative mb-4 flex select-none items-center justify-center gap-4 transition-all duration-700 ease-out xl:mb-6"
|
||||
:class="isMounted ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0'"
|
||||
>
|
||||
<img :src="logoSvg" alt="ChatLab" class="pointer-events-none h-10 w-10 select-none" />
|
||||
<h1 class="text-3xl font-bold leading-none tracking-tight text-gray-900 dark:text-white">
|
||||
{{ t('home.tagline') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full transition-all delay-200 duration-700 ease-out"
|
||||
:class="isMounted ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0'"
|
||||
>
|
||||
<ImportArea :backend-features="false" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-6 flex items-center gap-3 transition-all delay-300 duration-700 ease-out"
|
||||
:class="isMounted ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0'"
|
||||
>
|
||||
<UButton :href="tutorialExportUrl" target="_blank" trailing-icon="i-heroicons-chevron-right-20-solid">
|
||||
{{ t('home.quickStart.export') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeFooter
|
||||
:remote-config-enabled="false"
|
||||
@open-changelog="changelogModalRef?.open()"
|
||||
@open-terms="agreementModalRef?.open()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Web WASM intentionally does not gate its fully local import flow on agreement acceptance.
|
||||
Keep the agreement available from HomeFooter only; do not open it from LanguageSelectModal's done event.
|
||||
-->
|
||||
<LanguageSelectModal />
|
||||
<AgreementModal ref="agreementModalRef" />
|
||||
<ChangelogModal ref="changelogModalRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { EChartBar, EChartPie, RankList } from '@/components/charts'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import { LoadingState, SectionCard } from '@/components/UI'
|
||||
import { useDataService } from '@/services'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { HourlyActivity, MemberActivity } from '@/types/analysis'
|
||||
import { getMessageTypeName, type AnalysisSession } from '@/types/base'
|
||||
import type { MessageTypeStats } from '@openchatlab/core'
|
||||
import { buildHourlyChartData, buildMemberRankItems, buildMessageTypeChartData } from './session-detail'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const sessionStore = useSessionStore()
|
||||
|
||||
const session = ref<AnalysisSession | null>(null)
|
||||
const hourlyActivity = ref<HourlyActivity[]>([])
|
||||
const memberActivity = ref<MemberActivity[]>([])
|
||||
const messageTypes = ref<MessageTypeStats[]>([])
|
||||
const isLoading = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
let loadVersion = 0
|
||||
|
||||
const sessionId = computed(() => String(route.params.id ?? ''))
|
||||
const isPrivateChat = computed(() => route.name === 'private-chat')
|
||||
const chartData = computed(() => buildHourlyChartData(hourlyActivity.value))
|
||||
const memberRankItems = computed(() => buildMemberRankItems(memberActivity.value))
|
||||
const messageTypeChartData = computed(() =>
|
||||
buildMessageTypeChartData(messageTypes.value, (type) => getMessageTypeName(type, t))
|
||||
)
|
||||
const loadErrorText = computed(() =>
|
||||
t(isPrivateChat.value ? 'analysis.privateChat.loadError' : 'analysis.groupChat.loadError')
|
||||
)
|
||||
|
||||
async function loadSessionDetail() {
|
||||
const id = sessionId.value
|
||||
const version = ++loadVersion
|
||||
|
||||
if (!id) {
|
||||
session.value = null
|
||||
hourlyActivity.value = []
|
||||
memberActivity.value = []
|
||||
messageTypes.value = []
|
||||
loadFailed.value = true
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
sessionStore.selectSession(id)
|
||||
session.value = null
|
||||
hourlyActivity.value = []
|
||||
memberActivity.value = []
|
||||
messageTypes.value = []
|
||||
loadFailed.value = false
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const data = useDataService()
|
||||
const [sessionData, hourlyData, memberData, messageTypeData] = await Promise.all([
|
||||
data.getSession(id),
|
||||
data.getHourlyActivity(id),
|
||||
data.getMemberActivity(id),
|
||||
data.getMessageTypeDistribution(id),
|
||||
])
|
||||
if (version !== loadVersion) return
|
||||
|
||||
session.value = sessionData
|
||||
hourlyActivity.value = hourlyData
|
||||
memberActivity.value = memberData
|
||||
messageTypes.value = messageTypeData
|
||||
loadFailed.value = sessionData === null
|
||||
} catch (error) {
|
||||
if (version !== loadVersion) return
|
||||
console.error('[web-wasm-session] Failed to load session detail:', error)
|
||||
loadFailed.value = true
|
||||
} finally {
|
||||
if (version === loadVersion) isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(sessionId, loadSessionDetail, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
loadVersion += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
|
||||
<LoadingState v-if="isLoading" variant="page" :text="t('common.loading')" />
|
||||
|
||||
<template v-else-if="session">
|
||||
<PageHeader
|
||||
:title="session.name"
|
||||
:avatar="session.groupAvatar"
|
||||
:icon="isPrivateChat ? 'i-heroicons-user' : 'i-heroicons-chat-bubble-left-right'"
|
||||
:icon-class="isPrivateChat ? 'bg-pink-600 dark:bg-pink-500' : 'bg-primary-600 dark:bg-primary-500'"
|
||||
size="compact"
|
||||
/>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="main-content mx-auto max-w-[920px] space-y-6 p-4 sm:p-6">
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-left"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="sm"
|
||||
:label="t('common.back')"
|
||||
to="/"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 sm:gap-4">
|
||||
<div class="rounded-xl border border-gray-200/60 bg-white p-4 dark:border-white/5 dark:bg-card-dark">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('analysis.overview.identity.totalMessages') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-gray-900 dark:text-white">
|
||||
{{ session.messageCount.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200/60 bg-white p-4 dark:border-white/5 dark:bg-card-dark">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('views.cluster.totalMembers') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-gray-900 dark:text-white">
|
||||
{{ session.memberCount.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionCard
|
||||
:title="t('analysis.overview.messageTypeDistribution')"
|
||||
:capturable="false"
|
||||
:show-divider="false"
|
||||
>
|
||||
<div class="p-3 sm:p-5">
|
||||
<EChartPie v-if="messageTypeChartData.values.length > 0" :data="messageTypeChartData" :height="280" />
|
||||
<div v-else class="py-8 text-center text-sm text-gray-400">
|
||||
{{ t('views.message.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard :title="t('analysis.overview.memberRanking')" :capturable="false" :show-divider="false">
|
||||
<RankList v-if="memberRankItems.length > 0" :members="memberRankItems" :rank-limit="10" show-avatar />
|
||||
<div v-else class="py-8 text-center text-sm text-gray-400">
|
||||
{{ t('views.message.noData') }}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard :title="t('views.message.hourlyDistribution')" :capturable="false" :show-divider="false">
|
||||
<div class="p-3 sm:p-5">
|
||||
<EChartBar :data="chartData" :height="260" />
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="loadFailed" class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ loadErrorText }}</p>
|
||||
<UButton size="sm" variant="soft" to="/">{{ t('common.back') }}</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { buildHourlyChartData, buildMemberRankItems, buildMessageTypeChartData } from './session-detail'
|
||||
|
||||
test('builds a complete 24-hour chart and fills missing hours with zero', () => {
|
||||
const result = buildHourlyChartData([
|
||||
{ hour: 8, messageCount: 2 },
|
||||
{ hour: 23, messageCount: 1 },
|
||||
])
|
||||
|
||||
assert.equal(result.labels.length, 24)
|
||||
assert.equal(result.values.length, 24)
|
||||
assert.equal(result.labels[0], '0')
|
||||
assert.equal(result.labels[23], '23')
|
||||
assert.equal(result.values[8], 2)
|
||||
assert.equal(result.values[23], 1)
|
||||
assert.equal(result.values[7], 0)
|
||||
})
|
||||
|
||||
test('maps member activity into the shared ranking component contract', () => {
|
||||
assert.deepEqual(
|
||||
buildMemberRankItems([
|
||||
{
|
||||
memberId: 7,
|
||||
platformId: 'alice',
|
||||
name: 'Alice',
|
||||
avatar: null,
|
||||
messageCount: 12,
|
||||
percentage: 75,
|
||||
},
|
||||
]),
|
||||
[{ id: '7', name: 'Alice', value: 12, percentage: 75 }]
|
||||
)
|
||||
})
|
||||
|
||||
test('maps message type stats into the shared pie chart contract in descending order', () => {
|
||||
const source = [
|
||||
{ type: 0, count: 1 },
|
||||
{ type: 1, count: 3 },
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
buildMessageTypeChartData(source, (type) => `type-${type}`),
|
||||
{
|
||||
labels: ['type-1', 'type-0'],
|
||||
values: [3, 1],
|
||||
}
|
||||
)
|
||||
assert.deepEqual(source, [
|
||||
{ type: 0, count: 1 },
|
||||
{ type: 1, count: 3 },
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { HourlyActivity, MemberActivity } from '@/types/analysis'
|
||||
import type { MessageTypeStats } from '@openchatlab/core'
|
||||
import type { EChartBarData, EChartPieData, RankItem } from '@/components/charts'
|
||||
|
||||
export function buildHourlyChartData(hourlyActivity: readonly HourlyActivity[]): EChartBarData {
|
||||
const hourMap = new Map(hourlyActivity.map((item) => [item.hour, item.messageCount]))
|
||||
|
||||
return {
|
||||
labels: Array.from({ length: 24 }, (_, hour) => String(hour)),
|
||||
values: Array.from({ length: 24 }, (_, hour) => hourMap.get(hour) ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMemberRankItems(memberActivity: readonly MemberActivity[]): RankItem[] {
|
||||
return memberActivity.map((member) => ({
|
||||
id: String(member.memberId),
|
||||
name: member.name,
|
||||
value: member.messageCount,
|
||||
percentage: member.percentage,
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildMessageTypeChartData(
|
||||
messageTypes: readonly MessageTypeStats[],
|
||||
getLabel: (type: number) => string
|
||||
): EChartPieData {
|
||||
const sorted = [...messageTypes].sort((left, right) => right.count - left.count)
|
||||
return {
|
||||
labels: sorted.map((item) => getLabel(item.type)),
|
||||
values: sorted.map((item) => item.count),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { webWasmRoutes } from './routes'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: webWasmRoutes,
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
document.body.id = `page-${String(to.name ?? 'unknown')}`
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { webWasmRoutes } from './routes'
|
||||
|
||||
test('keeps Web WASM application routes independent from the backend client', () => {
|
||||
assert.deepEqual(
|
||||
webWasmRoutes.map(({ path, name }) => ({ path, name })),
|
||||
[
|
||||
{ path: '/', name: 'home' },
|
||||
{ path: '/group-chat/:id', name: 'group-chat' },
|
||||
{ path: '/private-chat/:id', name: 'private-chat' },
|
||||
]
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const webWasmRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('./pages/HomePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/group-chat/:id',
|
||||
name: 'group-chat',
|
||||
component: () => import('./pages/SessionDetailPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/private-chat/:id',
|
||||
name: 'private-chat',
|
||||
component: () => import('./pages/SessionDetailPage.vue'),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { handleWebWasmPageHide } from './runtime-lifecycle'
|
||||
|
||||
test('keeps the Web WASM runtime alive when the page may enter the back-forward cache', () => {
|
||||
let disposeCount = 0
|
||||
|
||||
handleWebWasmPageHide({ persisted: true }, () => disposeCount++)
|
||||
|
||||
assert.equal(disposeCount, 0)
|
||||
})
|
||||
|
||||
test('disposes the Web WASM runtime when the page is being discarded', () => {
|
||||
let disposeCount = 0
|
||||
|
||||
handleWebWasmPageHide({ persisted: false }, () => disposeCount++)
|
||||
|
||||
assert.equal(disposeCount, 1)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export function handleWebWasmPageHide(event: Pick<PageTransitionEvent, 'persisted'>, disposeRuntime: () => void): void {
|
||||
if (event.persisted) return
|
||||
disposeRuntime()
|
||||
}
|
||||
@@ -85,5 +85,55 @@ export default defineConfigWithVueTs(
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Standalone browser runtime and browser service adapters must not pull in
|
||||
// Electron, Node-only runtimes, AI runtimes, or CLI backend implementation.
|
||||
{
|
||||
files: ['packages/web-runtime/src/**/*.ts', 'src/services/**/browser.ts'],
|
||||
ignores: ['**/*.test.ts', '**/*.spec.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'electron',
|
||||
message: 'Standalone browser code cannot depend on Electron.',
|
||||
},
|
||||
{
|
||||
name: '@openchatlab/node-runtime',
|
||||
message: 'Standalone browser code cannot depend on the Node runtime.',
|
||||
},
|
||||
{
|
||||
name: '@openchatlab/http-routes',
|
||||
message: 'Standalone browser code cannot depend on HTTP server routes.',
|
||||
},
|
||||
{
|
||||
name: '@openchatlab/tools',
|
||||
message: 'Standalone browser MVP does not include the AI tool runtime.',
|
||||
},
|
||||
{
|
||||
name: '@openchatlab/config',
|
||||
message: 'Standalone browser code cannot depend on the Node-backed config package.',
|
||||
},
|
||||
{
|
||||
name: '@openchatlab/parser',
|
||||
message: 'Browser code must use an explicit browser-safe parser subpath, not the Node file-path parser.',
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ['node:*', '@electron/*', '**/apps/cli/**', '@/services/ai/**'],
|
||||
message: 'Standalone browser runtime and adapters must stay browser-only.',
|
||||
},
|
||||
{
|
||||
group: ['@openchatlab/parser/src/**'],
|
||||
message: 'Standalone browser code must use the public @openchatlab/parser/browser entrypoint.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -35,8 +35,10 @@
|
||||
"build:skill-validator": "node scripts/build-chatlab-skill-validator.mjs",
|
||||
"dev:serve": "bash scripts/dev-serve.sh",
|
||||
"dev:web": "pnpm run ensure:server-native && CHATLAB_AUTO_SERVE=1 vite --config vite.web.config.mts",
|
||||
"dev:web-wasm": "vite --config vite.web-wasm.config.mts",
|
||||
"ensure:server-native": "pnpm --filter chatlab-cli run ensure-native",
|
||||
"build:web": "vite build --config vite.web.config.mts",
|
||||
"build:web-wasm": "vite build --config vite.web-wasm.config.mts",
|
||||
"docs:dev": "pnpm --filter @openchatlab/docs dev",
|
||||
"docs:build": "pnpm --filter @openchatlab/docs build",
|
||||
"docs:preview": "pnpm --filter @openchatlab/docs preview",
|
||||
|
||||
@@ -706,4 +706,85 @@ describe('BrowserSessionRuntime', () => {
|
||||
assert.deepEqual(await database.getDatabaseFilenames(), filenamesBeforeMissingQuery)
|
||||
database.dispose()
|
||||
})
|
||||
|
||||
it('runs the core member activity query with ranking and time-filter semantics', async () => {
|
||||
const sqlite3 = await sqlite3InitModule()
|
||||
const database = new MemoryWorkspaceDatabase(sqlite3)
|
||||
const runtime = new BrowserSessionRuntime(database, {
|
||||
createSessionId: () => 'member-query-session',
|
||||
now: () => 100,
|
||||
})
|
||||
const timestamp = 1_700_000_000
|
||||
const fixture = {
|
||||
chatlab: { version: '1', exportedAt: timestamp },
|
||||
meta: { name: 'Member Query', platform: 'wechat', type: 'group', ownerId: 'alice' },
|
||||
members: [
|
||||
{ platformId: 'alice', accountName: 'Alice' },
|
||||
{ platformId: 'bob', accountName: 'Bob' },
|
||||
],
|
||||
messages: [
|
||||
{ sender: 'alice', accountName: 'Alice', timestamp, type: 0, content: 'one' },
|
||||
{ sender: 'alice', accountName: 'Alice', timestamp: timestamp + 1, type: 0, content: 'two' },
|
||||
{ sender: 'bob', accountName: 'Bob', timestamp: timestamp + 2, type: 0, content: 'three' },
|
||||
],
|
||||
}
|
||||
|
||||
await runtime.importSource(source('member-query.json', fixture), { formatId: 'chatlab' })
|
||||
|
||||
const ranking = await runtime.getMemberActivity('member-query-session')
|
||||
assert.deepEqual(
|
||||
ranking.map(({ name, messageCount, percentage }) => ({ name, messageCount, percentage })),
|
||||
[
|
||||
{ name: 'Alice', messageCount: 2, percentage: 66.67 },
|
||||
{ name: 'Bob', messageCount: 1, percentage: 33.33 },
|
||||
]
|
||||
)
|
||||
assert.deepEqual(
|
||||
(await runtime.getMemberActivity('member-query-session', { startTs: timestamp + 2 })).map(
|
||||
({ name, messageCount, percentage }) => ({ name, messageCount, percentage })
|
||||
),
|
||||
[{ name: 'Bob', messageCount: 1, percentage: 100 }]
|
||||
)
|
||||
|
||||
const filenamesBeforeMissingQuery = await database.getDatabaseFilenames()
|
||||
await assert.rejects(runtime.getMemberActivity('missing-session'), /Session missing-session was not found/)
|
||||
assert.deepEqual(await database.getDatabaseFilenames(), filenamesBeforeMissingQuery)
|
||||
database.dispose()
|
||||
})
|
||||
|
||||
it('runs the core message type query with filtering and without creating missing databases', async () => {
|
||||
const sqlite3 = await sqlite3InitModule()
|
||||
const database = new MemoryWorkspaceDatabase(sqlite3)
|
||||
const runtime = new BrowserSessionRuntime(database, {
|
||||
createSessionId: () => 'message-type-query-session',
|
||||
now: () => 100,
|
||||
})
|
||||
const timestamp = 1_700_000_000
|
||||
const fixture = {
|
||||
chatlab: { version: '1', exportedAt: timestamp },
|
||||
meta: { name: 'Message Type Query', platform: 'wechat', type: 'group', ownerId: 'alice' },
|
||||
members: [{ platformId: 'alice', accountName: 'Alice' }],
|
||||
messages: [
|
||||
{ sender: 'alice', accountName: 'Alice', timestamp, type: 0, content: 'one' },
|
||||
{ sender: 'alice', accountName: 'Alice', timestamp: timestamp + 1, type: 1, content: '[image]' },
|
||||
{ sender: 'alice', accountName: 'Alice', timestamp: timestamp + 2, type: 1, content: '[image]' },
|
||||
],
|
||||
}
|
||||
|
||||
await runtime.importSource(source('message-type-query.json', fixture), { formatId: 'chatlab' })
|
||||
|
||||
assert.deepEqual(await runtime.getMessageTypeDistribution('message-type-query-session'), [
|
||||
{ type: 1, count: 2 },
|
||||
{ type: 0, count: 1 },
|
||||
])
|
||||
assert.deepEqual(
|
||||
await runtime.getMessageTypeDistribution('message-type-query-session', { startTs: timestamp + 2 }),
|
||||
[{ type: 1, count: 1 }]
|
||||
)
|
||||
|
||||
const filenamesBeforeMissingQuery = await database.getDatabaseFilenames()
|
||||
await assert.rejects(runtime.getMessageTypeDistribution('missing-session'), /Session missing-session was not found/)
|
||||
assert.deepEqual(await database.getDatabaseFilenames(), filenamesBeforeMissingQuery)
|
||||
database.dispose()
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,12 @@ import {
|
||||
CHAT_DB_INDEXES,
|
||||
CHAT_DB_TABLES,
|
||||
getHourlyActivity as queryHourlyActivity,
|
||||
getMemberActivity as queryMemberActivity,
|
||||
getMessageTypeStats as queryMessageTypeStats,
|
||||
writeParseResultToDb,
|
||||
type HourlyActivity,
|
||||
type MemberActivity,
|
||||
type MessageTypeStats,
|
||||
} from '@openchatlab/core'
|
||||
import { WebRuntimeError } from '../runtime-error'
|
||||
import type { WorkspaceDatabasePort, WorkspaceDatabaseStage } from '../storage/workspace-database'
|
||||
@@ -243,6 +247,26 @@ export class BrowserSessionRuntime {
|
||||
queryHourlyActivity(db, filter)
|
||||
)
|
||||
}
|
||||
|
||||
async getMemberActivity(id: string, filter?: BrowserTimeFilter): Promise<MemberActivity[]> {
|
||||
validateSessionId(id)
|
||||
const session = await this.catalog.get(id)
|
||||
if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
|
||||
|
||||
return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) =>
|
||||
queryMemberActivity(db, filter)
|
||||
)
|
||||
}
|
||||
|
||||
async getMessageTypeDistribution(id: string, filter?: BrowserTimeFilter): Promise<MessageTypeStats[]> {
|
||||
validateSessionId(id)
|
||||
const session = await this.catalog.get(id)
|
||||
if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
|
||||
|
||||
return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) =>
|
||||
queryMessageTypeStats(db, filter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImportTimestamp(value: unknown): number | null {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BrowserImportFormatId, BrowserParseSource } from '../import/browser-parser'
|
||||
import type { BrowserSessionCatalogItem } from '../import/session-catalog'
|
||||
import type { HourlyActivity } from '@openchatlab/core'
|
||||
import type { HourlyActivity, MemberActivity, MessageTypeStats } from '@openchatlab/core'
|
||||
import type {
|
||||
BrowserImportFormatInfo,
|
||||
BrowserMultiChatEntry,
|
||||
@@ -77,6 +77,14 @@ export interface WebRuntimeTaskMap {
|
||||
payload: { sessionId: string; filter?: BrowserTimeFilter }
|
||||
result: HourlyActivity[]
|
||||
}
|
||||
'analysis.members': {
|
||||
payload: { sessionId: string; filter?: BrowserTimeFilter }
|
||||
result: MemberActivity[]
|
||||
}
|
||||
'analysis.messageTypes': {
|
||||
payload: { sessionId: string; filter?: BrowserTimeFilter }
|
||||
result: MessageTypeStats[]
|
||||
}
|
||||
}
|
||||
|
||||
export type WebRuntimeTaskType = keyof WebRuntimeTaskMap
|
||||
@@ -173,4 +181,6 @@ const WEB_RUNTIME_TASK_TYPES: Record<WebRuntimeTaskType, true> = {
|
||||
'session.delete': true,
|
||||
'session.rename': true,
|
||||
'analysis.hourly': true,
|
||||
'analysis.members': true,
|
||||
'analysis.messageTypes': true,
|
||||
}
|
||||
@@ -71,6 +71,8 @@ class FakeDatabaseRuntime implements WorkerDatabaseRuntime {
|
||||
|
||||
class FakeSessionRuntime implements WorkerSessionRuntime {
|
||||
readonly hourlyCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
|
||||
readonly memberCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
|
||||
readonly messageTypeCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
|
||||
readonly importCalls: Array<{ chatIndex?: number }> = []
|
||||
readonly session = {
|
||||
id: 'session-one',
|
||||
@@ -135,6 +137,32 @@ class FakeSessionRuntime implements WorkerSessionRuntime {
|
||||
? Array.from({ length: 24 }, (_, hour) => ({ hour, messageCount: hour === 8 ? 2 : 0 }))
|
||||
: []
|
||||
}
|
||||
|
||||
async getMemberActivity(id: string, filter?: BrowserTimeFilter) {
|
||||
this.memberCalls.push({ id, filter })
|
||||
return id === this.session.id
|
||||
? [
|
||||
{
|
||||
memberId: 1,
|
||||
platformId: 'alice',
|
||||
name: 'Alice',
|
||||
avatar: null,
|
||||
messageCount: 2,
|
||||
percentage: 100,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
||||
async getMessageTypeDistribution(id: string, filter?: BrowserTimeFilter) {
|
||||
this.messageTypeCalls.push({ id, filter })
|
||||
return id === this.session.id
|
||||
? [
|
||||
{ type: 1, count: 2 },
|
||||
{ type: 0, count: 1 },
|
||||
]
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('WebRuntimeWorkerController', () => {
|
||||
@@ -274,6 +302,16 @@ describe('WebRuntimeWorkerController', () => {
|
||||
type: 'analysis.hourly',
|
||||
payload: { sessionId: 'session-one', filter: { startTs: 1 } },
|
||||
})
|
||||
controller.handleMessage({
|
||||
id: 'members-1',
|
||||
type: 'analysis.members',
|
||||
payload: { sessionId: 'session-one', filter: { endTs: 2 } },
|
||||
})
|
||||
controller.handleMessage({
|
||||
id: 'message-types-1',
|
||||
type: 'analysis.messageTypes',
|
||||
payload: { sessionId: 'session-one', filter: { startTs: 1, endTs: 2 } },
|
||||
})
|
||||
controller.handleMessage({
|
||||
id: 'rename-1',
|
||||
type: 'session.rename',
|
||||
@@ -285,6 +323,8 @@ describe('WebRuntimeWorkerController', () => {
|
||||
const imported = await waitForMessage(sink, 'import-1', 'result')
|
||||
const listed = await waitForMessage(sink, 'list-1', 'result')
|
||||
const hourly = await waitForMessage(sink, 'hourly-1', 'result')
|
||||
const members = await waitForMessage(sink, 'members-1', 'result')
|
||||
const messageTypes = await waitForMessage(sink, 'message-types-1', 'result')
|
||||
const renamed = await waitForMessage(sink, 'rename-1', 'result')
|
||||
|
||||
assert.deepEqual(detected.type === 'result' ? detected.payload.result : null, sessions.getSupportedFormats()[0])
|
||||
@@ -304,6 +344,25 @@ describe('WebRuntimeWorkerController', () => {
|
||||
Array.from({ length: 24 }, (_, hour) => ({ hour, messageCount: hour === 8 ? 2 : 0 }))
|
||||
)
|
||||
assert.deepEqual(sessions.hourlyCalls[0], { id: 'session-one', filter: { startTs: 1 } })
|
||||
assert.deepEqual(members.type === 'result' ? members.payload.result : null, [
|
||||
{
|
||||
memberId: 1,
|
||||
platformId: 'alice',
|
||||
name: 'Alice',
|
||||
avatar: null,
|
||||
messageCount: 2,
|
||||
percentage: 100,
|
||||
},
|
||||
])
|
||||
assert.deepEqual(sessions.memberCalls[0], { id: 'session-one', filter: { endTs: 2 } })
|
||||
assert.deepEqual(messageTypes.type === 'result' ? messageTypes.payload.result : null, [
|
||||
{ type: 1, count: 2 },
|
||||
{ type: 0, count: 1 },
|
||||
])
|
||||
assert.deepEqual(sessions.messageTypeCalls[0], {
|
||||
id: 'session-one',
|
||||
filter: { startTs: 1, endTs: 2 },
|
||||
})
|
||||
assert.deepEqual(sessions.importCalls, [{ chatIndex: 1 }])
|
||||
assert.deepEqual(renamed.type === 'result' ? renamed.payload.result : null, { renamed: true })
|
||||
assert.equal(
|
||||
|
||||
@@ -35,6 +35,8 @@ export type WorkerSessionRuntime = Pick<
|
||||
| 'deleteSession'
|
||||
| 'renameSession'
|
||||
| 'getHourlyActivity'
|
||||
| 'getMemberActivity'
|
||||
| 'getMessageTypeDistribution'
|
||||
>
|
||||
|
||||
export class WebRuntimeWorkerController {
|
||||
@@ -209,6 +211,33 @@ export class WebRuntimeWorkerController {
|
||||
})
|
||||
return result
|
||||
}
|
||||
case 'analysis.members': {
|
||||
this.assertSupportedBrowser()
|
||||
const startedAt = performance.now()
|
||||
const result = await this.sessionRuntime.getMemberActivity(request.payload.sessionId, request.payload.filter)
|
||||
this.emitLog(request.id, {
|
||||
level: 'debug',
|
||||
scope: 'web-runtime',
|
||||
message: 'Browser member activity query completed',
|
||||
data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
|
||||
})
|
||||
return result
|
||||
}
|
||||
case 'analysis.messageTypes': {
|
||||
this.assertSupportedBrowser()
|
||||
const startedAt = performance.now()
|
||||
const result = await this.sessionRuntime.getMessageTypeDistribution(
|
||||
request.payload.sessionId,
|
||||
request.payload.filter
|
||||
)
|
||||
this.emitLog(request.id, {
|
||||
level: 'debug',
|
||||
scope: 'web-runtime',
|
||||
message: 'Browser message type distribution query completed',
|
||||
data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ test('does not open Web WASM session storage when required browser capabilities
|
||||
return unsupported
|
||||
},
|
||||
initializePreferences: async () => void calls.push('preferences'),
|
||||
initializeLocale: async () => void calls.push('locale'),
|
||||
loadSessions: async () => void calls.push('sessions'),
|
||||
}),
|
||||
(error: unknown) =>
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface AppInitializationPorts {
|
||||
initializeServices(): Promise<void>
|
||||
checkBrowserCapabilities?: () => Promise<BrowserCapabilityReport>
|
||||
initializePreferences(): Promise<void>
|
||||
initializeLocale?: () => Promise<void>
|
||||
initializeLocale(): Promise<void>
|
||||
initializeLlm?: () => Promise<void>
|
||||
loadSessions(): Promise<void>
|
||||
listenForPullResults?: () => () => void
|
||||
|
||||
@@ -39,9 +39,25 @@ describe('BrowserDataAdapter', () => {
|
||||
? item
|
||||
: type === 'analysis.hourly'
|
||||
? Array.from({ length: 24 }, (_, hour) => ({ hour, messageCount: hour === 8 ? 2 : 0 }))
|
||||
: type === 'session.delete'
|
||||
? { deleted: true }
|
||||
: { renamed: true }
|
||||
: type === 'analysis.members'
|
||||
? [
|
||||
{
|
||||
memberId: 1,
|
||||
platformId: 'alice',
|
||||
name: 'Alice',
|
||||
avatar: null,
|
||||
messageCount: 2,
|
||||
percentage: 100,
|
||||
},
|
||||
]
|
||||
: type === 'analysis.messageTypes'
|
||||
? [
|
||||
{ type: 1, count: 2 },
|
||||
{ type: 0, count: 1 },
|
||||
]
|
||||
: type === 'session.delete'
|
||||
? { deleted: true }
|
||||
: { renamed: true }
|
||||
return result as WebRuntimeTaskResult<T>
|
||||
},
|
||||
dispose: () => undefined,
|
||||
@@ -70,12 +86,22 @@ describe('BrowserDataAdapter', () => {
|
||||
})
|
||||
assert.equal((await adapter.getSession('session-one'))?.id, 'session-one')
|
||||
assert.equal((await adapter.getHourlyActivity('session-one', { startTs: 1 }))[8].messageCount, 2)
|
||||
assert.equal((await adapter.getMemberActivity('session-one', { endTs: 2 }))[0].name, 'Alice')
|
||||
assert.deepEqual(await adapter.getMessageTypeDistribution('session-one', { startTs: 1, endTs: 2 }), [
|
||||
{ type: 1, count: 2 },
|
||||
{ type: 0, count: 1 },
|
||||
])
|
||||
assert.equal(await adapter.renameSession('session-one', 'New name'), true)
|
||||
assert.equal(await adapter.deleteSession('session-one'), true)
|
||||
assert.deepEqual(requests, [
|
||||
{ type: 'session.list', payload: undefined },
|
||||
{ type: 'session.get', payload: { sessionId: 'session-one' } },
|
||||
{ type: 'analysis.hourly', payload: { sessionId: 'session-one', filter: { startTs: 1 } } },
|
||||
{ type: 'analysis.members', payload: { sessionId: 'session-one', filter: { endTs: 2 } } },
|
||||
{
|
||||
type: 'analysis.messageTypes',
|
||||
payload: { sessionId: 'session-one', filter: { startTs: 1, endTs: 2 } },
|
||||
},
|
||||
{ type: 'session.rename', payload: { sessionId: 'session-one', name: 'New name' } },
|
||||
{ type: 'session.delete', payload: { sessionId: 'session-one' } },
|
||||
])
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { sessionDatabaseFilename, type BrowserSessionCatalogItem } from '@openchatlab/web-runtime'
|
||||
import type { AnalysisSession } from '@/types/base'
|
||||
import type { HourlyActivity } from '@/types/analysis'
|
||||
import type { AnalysisSession, MessageType } from '@/types/base'
|
||||
import type { HourlyActivity, MemberActivity } from '@/types/analysis'
|
||||
import type { TimeFilter } from '@openchatlab/shared-types'
|
||||
import type { BrowserRuntimeRpcPort } from '../browser-runtime/types'
|
||||
import type { DataAdapter } from './types'
|
||||
|
||||
type BrowserSessionDataAdapter = Pick<
|
||||
DataAdapter,
|
||||
'getSessions' | 'getSession' | 'deleteSession' | 'renameSession' | 'getHourlyActivity'
|
||||
| 'getSessions'
|
||||
| 'getSession'
|
||||
| 'deleteSession'
|
||||
| 'renameSession'
|
||||
| 'getHourlyActivity'
|
||||
| 'getMemberActivity'
|
||||
| 'getMessageTypeDistribution'
|
||||
>
|
||||
|
||||
export class BrowserDataAdapter implements BrowserSessionDataAdapter {
|
||||
@@ -33,6 +39,20 @@ export class BrowserDataAdapter implements BrowserSessionDataAdapter {
|
||||
getHourlyActivity(sessionId: string, filter?: TimeFilter): Promise<HourlyActivity[]> {
|
||||
return this.rpc.request('analysis.hourly', { sessionId, filter })
|
||||
}
|
||||
|
||||
getMemberActivity(sessionId: string, filter?: TimeFilter): Promise<MemberActivity[]> {
|
||||
return this.rpc.request('analysis.members', { sessionId, filter })
|
||||
}
|
||||
|
||||
async getMessageTypeDistribution(
|
||||
sessionId: string,
|
||||
filter?: TimeFilter
|
||||
): Promise<Array<{ type: MessageType; count: number }>> {
|
||||
return (await this.rpc.request('analysis.messageTypes', { sessionId, filter })).map((item) => ({
|
||||
type: item.type as MessageType,
|
||||
count: item.count,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserDataAdapter(rpc: BrowserRuntimeRpcPort): DataAdapter {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { registerAdapter } from '@/services/registry'
|
||||
import type { DataAdapter } from '@/services'
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => void values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
function installMemoryStorage() {
|
||||
Object.defineProperties(globalThis, {
|
||||
localStorage: { configurable: true, value: createMemoryStorage() },
|
||||
sessionStorage: { configurable: true, value: createMemoryStorage() },
|
||||
})
|
||||
}
|
||||
|
||||
test('strict startup session loading preserves the retry state and rethrows data errors', async (t) => {
|
||||
installMemoryStorage()
|
||||
const failure = new Error('catalog unavailable')
|
||||
registerAdapter('data', {
|
||||
getSessions: async () => Promise.reject(failure),
|
||||
} as unknown as DataAdapter)
|
||||
setActivePinia(createPinia())
|
||||
const { useSessionStore } = await import('./session')
|
||||
const store = useSessionStore()
|
||||
t.mock.method(console, 'error', () => undefined)
|
||||
|
||||
await assert.rejects(
|
||||
() => store.loadSessions({ throwOnError: true }),
|
||||
(error: unknown) => error === failure
|
||||
)
|
||||
assert.equal(store.isInitialized, false)
|
||||
})
|
||||
|
||||
test('ordinary session refresh remains initialized when data loading fails', async (t) => {
|
||||
installMemoryStorage()
|
||||
registerAdapter('data', {
|
||||
getSessions: async () => Promise.reject(new Error('refresh unavailable')),
|
||||
} as unknown as DataAdapter)
|
||||
setActivePinia(createPinia())
|
||||
const { useSessionStore } = await import('./session')
|
||||
const store = useSessionStore()
|
||||
t.mock.method(console, 'error', () => undefined)
|
||||
|
||||
await store.loadSessions()
|
||||
|
||||
assert.equal(store.isInitialized, true)
|
||||
})
|
||||
@@ -169,7 +169,7 @@ export const useSessionStore = defineStore(
|
||||
/**
|
||||
* 从数据库加载会话列表
|
||||
*/
|
||||
async function loadSessions() {
|
||||
async function loadSessions(options: { throwOnError?: boolean } = {}) {
|
||||
try {
|
||||
const list = await useDataService().getSessions()
|
||||
sessions.value = list
|
||||
@@ -180,6 +180,10 @@ export const useSessionStore = defineStore(
|
||||
isInitialized.value = true
|
||||
} catch (error) {
|
||||
console.error('加载会话列表失败:', error)
|
||||
if (options.throwOnError) {
|
||||
isInitialized.value = false
|
||||
throw error
|
||||
}
|
||||
isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": ["src/env.d.ts", "src/**/*", "apps/desktop/preload/**/*.d.ts", "packages/**/*"],
|
||||
"include": ["src/env.d.ts", "src/**/*", "apps/desktop/preload/**/*.d.ts", "apps/web-wasm/src/**/*", "packages/**/*"],
|
||||
"exclude": ["**/dist/**", "packages/*/bin/**"],
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const rootDir = import.meta.dirname
|
||||
|
||||
export default defineConfig({
|
||||
root: resolve(rootDir, 'apps/web-wasm'),
|
||||
base: '/',
|
||||
plugins: [
|
||||
vue(),
|
||||
ui({
|
||||
dts: false,
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'pink',
|
||||
neutral: 'zinc',
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(rootDir, 'src'),
|
||||
'~': resolve(rootDir, 'src'),
|
||||
'@openchatlab': resolve(rootDir, 'packages'),
|
||||
'@electron/shared': resolve(rootDir, 'apps/desktop/shared'),
|
||||
'@electron/preload': resolve(rootDir, 'apps/desktop/preload'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__IS_ELECTRON__: JSON.stringify(false),
|
||||
__IS_WEB_WASM__: JSON.stringify(true),
|
||||
__APP_VERSION__: JSON.stringify(JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf8')).version),
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm'],
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(rootDir, 'dist-web-wasm'),
|
||||
emptyOutDir: true,
|
||||
target: 'esnext',
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 3130,
|
||||
open: true,
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user