From 5f92d953c0f08cc7b47264b24096fa19952b04a0 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 19 Jul 2026 17:52:11 +0800 Subject: [PATCH] feat(web): add Web WASM application shell --- .github/workflows/test.yml | 3 + .gitignore | 1 + apps/web-wasm/index.html | 17 ++ apps/web-wasm/src/App.vue | 115 ++++++++++++ apps/web-wasm/src/main.ts | 47 +++++ apps/web-wasm/src/pages/HomePage.vue | 79 ++++++++ apps/web-wasm/src/pages/SessionDetailPage.vue | 168 ++++++++++++++++++ .../web-wasm/src/pages/session-detail.test.ts | 53 ++++++ apps/web-wasm/src/pages/session-detail.ts | 32 ++++ apps/web-wasm/src/router.ts | 11 ++ apps/web-wasm/src/routes.test.ts | 14 ++ apps/web-wasm/src/routes.ts | 19 ++ apps/web-wasm/src/runtime-lifecycle.test.ts | 19 ++ apps/web-wasm/src/runtime-lifecycle.ts | 4 + eslint.config.mjs | 50 ++++++ package.json | 2 + .../src/import/session-runtime.test.ts | 81 +++++++++ .../web-runtime/src/import/session-runtime.ts | 24 +++ packages/web-runtime/src/rpc/protocol.ts | 12 +- .../web-runtime/src/worker/controller.test.ts | 59 ++++++ packages/web-runtime/src/worker/controller.ts | 29 +++ src/bootstrap/app-initialization.test.ts | 1 + src/bootstrap/app-initialization.ts | 2 +- src/services/data/browser.test.ts | 32 +++- src/services/data/browser.ts | 26 ++- src/stores/session-load.test.ts | 59 ++++++ src/stores/session.ts | 6 +- tsconfig.web.json | 2 +- vite.web-wasm.config.mts | 55 ++++++ 29 files changed, 1012 insertions(+), 10 deletions(-) create mode 100644 apps/web-wasm/index.html create mode 100644 apps/web-wasm/src/App.vue create mode 100644 apps/web-wasm/src/main.ts create mode 100644 apps/web-wasm/src/pages/HomePage.vue create mode 100644 apps/web-wasm/src/pages/SessionDetailPage.vue create mode 100644 apps/web-wasm/src/pages/session-detail.test.ts create mode 100644 apps/web-wasm/src/pages/session-detail.ts create mode 100644 apps/web-wasm/src/router.ts create mode 100644 apps/web-wasm/src/routes.test.ts create mode 100644 apps/web-wasm/src/routes.ts create mode 100644 apps/web-wasm/src/runtime-lifecycle.test.ts create mode 100644 apps/web-wasm/src/runtime-lifecycle.ts create mode 100644 src/stores/session-load.test.ts create mode 100644 vite.web-wasm.config.mts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8237ee5e..7690e499 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore index cc0121f6..40d720a6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ error-log-*.txt # Project dist/ dist-web/ +dist-web-wasm/ docs/.vitepress/cache/ docs/.vitepress/dist/ apps/cli/native/ diff --git a/apps/web-wasm/index.html b/apps/web-wasm/index.html new file mode 100644 index 00000000..67b8d710 --- /dev/null +++ b/apps/web-wasm/index.html @@ -0,0 +1,17 @@ + + + + + + + + ChatLab Web + + +
+ + + diff --git a/apps/web-wasm/src/App.vue b/apps/web-wasm/src/App.vue new file mode 100644 index 00000000..d586840c --- /dev/null +++ b/apps/web-wasm/src/App.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/apps/web-wasm/src/main.ts b/apps/web-wasm/src/main.ts new file mode 100644 index 00000000..cbe65719 --- /dev/null +++ b/apps/web-wasm/src/main.ts @@ -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 { + 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('#app') + if (root) root.textContent = error instanceof Error ? error.message : String(error) +}) diff --git a/apps/web-wasm/src/pages/HomePage.vue b/apps/web-wasm/src/pages/HomePage.vue new file mode 100644 index 00000000..1a203fbc --- /dev/null +++ b/apps/web-wasm/src/pages/HomePage.vue @@ -0,0 +1,79 @@ + + + diff --git a/apps/web-wasm/src/pages/SessionDetailPage.vue b/apps/web-wasm/src/pages/SessionDetailPage.vue new file mode 100644 index 00000000..05513e7b --- /dev/null +++ b/apps/web-wasm/src/pages/SessionDetailPage.vue @@ -0,0 +1,168 @@ + + + diff --git a/apps/web-wasm/src/pages/session-detail.test.ts b/apps/web-wasm/src/pages/session-detail.test.ts new file mode 100644 index 00000000..920862d0 --- /dev/null +++ b/apps/web-wasm/src/pages/session-detail.test.ts @@ -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 }, + ]) +}) diff --git a/apps/web-wasm/src/pages/session-detail.ts b/apps/web-wasm/src/pages/session-detail.ts new file mode 100644 index 00000000..66557ea5 --- /dev/null +++ b/apps/web-wasm/src/pages/session-detail.ts @@ -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), + } +} diff --git a/apps/web-wasm/src/router.ts b/apps/web-wasm/src/router.ts new file mode 100644 index 00000000..4e80accc --- /dev/null +++ b/apps/web-wasm/src/router.ts @@ -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')}` +}) diff --git a/apps/web-wasm/src/routes.test.ts b/apps/web-wasm/src/routes.test.ts new file mode 100644 index 00000000..b4f5a653 --- /dev/null +++ b/apps/web-wasm/src/routes.test.ts @@ -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' }, + ] + ) +}) diff --git a/apps/web-wasm/src/routes.ts b/apps/web-wasm/src/routes.ts new file mode 100644 index 00000000..8345931e --- /dev/null +++ b/apps/web-wasm/src/routes.ts @@ -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'), + }, +] diff --git a/apps/web-wasm/src/runtime-lifecycle.test.ts b/apps/web-wasm/src/runtime-lifecycle.test.ts new file mode 100644 index 00000000..3e2983e4 --- /dev/null +++ b/apps/web-wasm/src/runtime-lifecycle.test.ts @@ -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) +}) diff --git a/apps/web-wasm/src/runtime-lifecycle.ts b/apps/web-wasm/src/runtime-lifecycle.ts new file mode 100644 index 00000000..ed888cd0 --- /dev/null +++ b/apps/web-wasm/src/runtime-lifecycle.ts @@ -0,0 +1,4 @@ +export function handleWebWasmPageHide(event: Pick, disposeRuntime: () => void): void { + if (event.persisted) return + disposeRuntime() +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 66f5e6df..08c8bd02 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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.', + }, + ], + }, + ], + }, } ) diff --git a/package.json b/package.json index 614b6052..474c18d5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/web-runtime/src/import/session-runtime.test.ts b/packages/web-runtime/src/import/session-runtime.test.ts index 0057b67e..95065e8d 100644 --- a/packages/web-runtime/src/import/session-runtime.test.ts +++ b/packages/web-runtime/src/import/session-runtime.test.ts @@ -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() + }) }) diff --git a/packages/web-runtime/src/import/session-runtime.ts b/packages/web-runtime/src/import/session-runtime.ts index 5b6e7759..364a3af5 100644 --- a/packages/web-runtime/src/import/session-runtime.ts +++ b/packages/web-runtime/src/import/session-runtime.ts @@ -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 { + 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 { + 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 { diff --git a/packages/web-runtime/src/rpc/protocol.ts b/packages/web-runtime/src/rpc/protocol.ts index d696a077..6c4e23bd 100644 --- a/packages/web-runtime/src/rpc/protocol.ts +++ b/packages/web-runtime/src/rpc/protocol.ts @@ -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 = { 'session.delete': true, 'session.rename': true, 'analysis.hourly': true, + 'analysis.members': true, + 'analysis.messageTypes': true, } diff --git a/packages/web-runtime/src/worker/controller.test.ts b/packages/web-runtime/src/worker/controller.test.ts index d354dd14..63a1babc 100644 --- a/packages/web-runtime/src/worker/controller.test.ts +++ b/packages/web-runtime/src/worker/controller.test.ts @@ -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( diff --git a/packages/web-runtime/src/worker/controller.ts b/packages/web-runtime/src/worker/controller.ts index 449cfba4..4f61f59b 100644 --- a/packages/web-runtime/src/worker/controller.ts +++ b/packages/web-runtime/src/worker/controller.ts @@ -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 + } } } diff --git a/src/bootstrap/app-initialization.test.ts b/src/bootstrap/app-initialization.test.ts index 46829c85..30d88a96 100644 --- a/src/bootstrap/app-initialization.test.ts +++ b/src/bootstrap/app-initialization.test.ts @@ -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) => diff --git a/src/bootstrap/app-initialization.ts b/src/bootstrap/app-initialization.ts index e36be00c..7bb1bfbe 100644 --- a/src/bootstrap/app-initialization.ts +++ b/src/bootstrap/app-initialization.ts @@ -13,7 +13,7 @@ export interface AppInitializationPorts { initializeServices(): Promise checkBrowserCapabilities?: () => Promise initializePreferences(): Promise - initializeLocale?: () => Promise + initializeLocale(): Promise initializeLlm?: () => Promise loadSessions(): Promise listenForPullResults?: () => () => void diff --git a/src/services/data/browser.test.ts b/src/services/data/browser.test.ts index a8df5ffb..a38c424f 100644 --- a/src/services/data/browser.test.ts +++ b/src/services/data/browser.test.ts @@ -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 }, 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' } }, ]) diff --git a/src/services/data/browser.ts b/src/services/data/browser.ts index 591fb36d..ad4556e1 100644 --- a/src/services/data/browser.ts +++ b/src/services/data/browser.ts @@ -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 { return this.rpc.request('analysis.hourly', { sessionId, filter }) } + + getMemberActivity(sessionId: string, filter?: TimeFilter): Promise { + return this.rpc.request('analysis.members', { sessionId, filter }) + } + + async getMessageTypeDistribution( + sessionId: string, + filter?: TimeFilter + ): Promise> { + 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 { diff --git a/src/stores/session-load.test.ts b/src/stores/session-load.test.ts new file mode 100644 index 00000000..4c0474a9 --- /dev/null +++ b/src/stores/session-load.test.ts @@ -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() + 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) +}) diff --git a/src/stores/session.ts b/src/stores/session.ts index 711e05d5..68b856fd 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -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 } } diff --git a/tsconfig.web.json b/tsconfig.web.json index 09e8d2e2..d10b7f55 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -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", diff --git a/vite.web-wasm.config.mts b/vite.web-wasm.config.mts new file mode 100644 index 00000000..34625120 --- /dev/null +++ b/vite.web-wasm.config.mts @@ -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, + }, +})