feat(web): add Web WASM application shell

This commit is contained in:
digua committed 2026-07-20 11:51:17 +08:00
1 parent ff6a397d4f
commit 5f92d953c0
29 files changed
+1012 -10

No files matched your search

+3
View File
@@ -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
+1
View File
@@ -24,6 +24,7 @@ error-log-*.txt
# Project
dist/
dist-web/
dist-web-wasm/
docs/.vitepress/cache/
docs/.vitepress/dist/
apps/cli/native/
+17
View File
@@ -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>
+115
View File
@@ -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>
+47
View File
@@ -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)
})
+79
View File
@@ -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 },
])
})
+32
View File
@@ -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),
}
}
+11
View File
@@ -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')}`
})
+14
View File
@@ -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' },
]
)
})
+19
View File
@@ -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)
})
+4
View File
@@ -0,0 +1,4 @@
export function handleWebWasmPageHide(event: Pick<PageTransitionEvent, 'persisted'>, disposeRuntime: () => void): void {
if (event.persisted) return
disposeRuntime()
}
+50
View File
@@ -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.',
},
],
},
],
},
}
)
+2
View File
@@ -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 {
+11 -1
View File
@@ -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
}
}
}
+1
View File
@@ -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) =>
+1 -1
View File
@@ -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
+29 -3
View File
@@ -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' } },
])
+23 -3
View File
@@ -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 {
+59
View File
@@ -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)
})
+5 -1
View File
@@ -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
View File
@@ -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",
+55
View File
@@ -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,
},
})