diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index 62271013..2fb491c9 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -45,7 +45,7 @@ import { MergeSessionCache, type SemanticIndexRuntime, } from '@openchatlab/node-runtime' -import { registerSharedRoutes } from '@openchatlab/http-routes' +import { annualSummaryNodePlugin, registerSharedRoutes } from '@openchatlab/http-routes' import type { HttpRouteContext } from '@openchatlab/http-routes' import { registerImportRoutes } from './import' import { openDirectoryPath, showPathInFolder } from './cache' @@ -176,7 +176,10 @@ export function registerWebRoutes( runAgentStream: ai.runAgentStream, }), }, - ai ? { requireAi: true } : undefined + { + nodePlugins: [annualSummaryNodePlugin], + ...(ai && { requireAi: true }), + } ) // CLI-specific routes not yet migrated to @openchatlab/http-routes diff --git a/apps/desktop/main/internal-api/server.ts b/apps/desktop/main/internal-api/server.ts index ff494c5b..b36088fd 100644 --- a/apps/desktop/main/internal-api/server.ts +++ b/apps/desktop/main/internal-api/server.ts @@ -29,7 +29,7 @@ import { import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime' import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec' import multipart from '@fastify/multipart' -import { registerSharedRoutes, registerAutomationRoutes } from '@openchatlab/http-routes' +import { annualSummaryNodePlugin, registerSharedRoutes, registerAutomationRoutes } from '@openchatlab/http-routes' import { createApiServer } from '@openchatlab/http-routes/server' import type { HttpRouteContext } from '@openchatlab/http-routes' import { reloadTimer, stopTimer, type DataSourceManager, type PullEngine } from '@openchatlab/sync' @@ -206,7 +206,10 @@ export async function startInternalServer( devOrigin: process.env.ELECTRON_RENDERER_URL || 'http://localhost:13100', }) - registerSharedRoutes(newServer, ctx, { requireAi: true }) + registerSharedRoutes(newServer, ctx, { + requireAi: true, + nodePlugins: [annualSummaryNodePlugin], + }) registerAutomationRoutes(newServer, ctx) await newServer.listen({ port: 0, host: '127.0.0.1' }) diff --git a/apps/web-wasm/src/main.ts b/apps/web-wasm/src/main.ts index 88a4a01b..0bcdc86f 100644 --- a/apps/web-wasm/src/main.ts +++ b/apps/web-wasm/src/main.ts @@ -17,6 +17,8 @@ import { handleWebWasmPageHide } from './runtime-lifecycle' import { WebWasmSessionSync } from './session-sync' import { useSessionStore } from '@/stores/session' import { initializeWebWasmLocale } from './locale-bootstrap' +import { installInsightPluginRuntime } from '@/plugins/insight-vue' +import { webWasmInsightRuntime, webWasmUiHost } from '@/plugins/web-wasm' import '@/assets/styles/main.css' initializeWebWasmLocale() @@ -50,6 +52,7 @@ async function start(): Promise { app.use(router) app.use(ui) app.use(i18n) + installInsightPluginRuntime(app, webWasmInsightRuntime) const sessionStore = useSessionStore(pinia) let refreshRequested = false @@ -76,6 +79,8 @@ async function start(): Promise { handleWebWasmPageHide(event, () => { unsubscribeSessionSync() sessionSync.dispose() + webWasmInsightRuntime.disposeAll() + webWasmUiHost.locale.dispose() runtime.dispose() }) ) diff --git a/apps/web-wasm/src/routes.ts b/apps/web-wasm/src/routes.ts index b39244b8..89fe9f45 100644 --- a/apps/web-wasm/src/routes.ts +++ b/apps/web-wasm/src/routes.ts @@ -25,6 +25,7 @@ export const webWasmRoutes: RouteRecordRaw[] = [ path: 'time-investment', name: 'insight-time-investment', component: () => import('@/pages/insight/time-investment/index.vue'), + meta: { insightPageId: 'time-investment' }, }, ], }, diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index ca39f684..9d240e5e 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -8,6 +8,9 @@ export type { } from './context' export { registerSharedRoutes } from './register' export type { SharedRouteOptions } from './register' +export { annualSummaryNodePlugin } from './plugins/builtin/annual-summary' +export { registerNodePlugins } from './plugins/node' +export type { NodePluginContext, NodePluginDescriptor } from './plugins/node' export { executeRegistryTool } from './ai/tool-executor' export type { AiToolExecutionDeps } from './ai/tool-executor' export { registerAutomationRoutes } from './routes/web/automation' diff --git a/packages/http-routes/src/routes/web/global-insight.test.ts b/packages/http-routes/src/plugins/builtin/annual-summary/index.test.ts similarity index 52% rename from packages/http-routes/src/routes/web/global-insight.test.ts rename to packages/http-routes/src/plugins/builtin/annual-summary/index.test.ts index 3aa83558..d3eaf5b6 100644 --- a/packages/http-routes/src/routes/web/global-insight.test.ts +++ b/packages/http-routes/src/plugins/builtin/annual-summary/index.test.ts @@ -1,18 +1,15 @@ import assert from 'node:assert/strict' import test from 'node:test' import Fastify from 'fastify' -import type { AnnualSummaryResponse, TimeInvestmentResponse } from '@openchatlab/shared-types' +import type { PathProvider } from '@openchatlab/core' import type { GlobalInsightService, GlobalInsightServiceOptions, SessionRuntimeAdapter, - TimeInvestmentService, - TimeInvestmentServiceOptions, } from '@openchatlab/node-runtime' -import type { PathProvider } from '@openchatlab/core' -import { registerGlobalInsightRoutes } from './global-insight' - -type GlobalInsightRouteContext = Parameters[1] +import type { AnnualSummaryResponse } from '@openchatlab/shared-types' +import { registerNodePlugins, type NodePluginContext } from '../../node' +import { annualSummaryNodePlugin } from '.' function emptyResponse(): AnnualSummaryResponse { return { @@ -44,83 +41,51 @@ function emptyResponse(): AnnualSummaryResponse { } } -function emptyTimeInvestmentResponse(): TimeInvestmentResponse { - const annual = emptyResponse() - return { - range: annual.range, - availableDataYears: [], - latestDataYear: null, - metrics: null, - monthlyActivity: [], - dailyActivity: [], - sessionRanking: [], - chatTypes: [], - coverage: annual.coverage, - cache: annual.cache, - task: annual.task, - } -} - class FakeService implements GlobalInsightService { getCalls: GlobalInsightServiceOptions[] = [] recomputeCalls: GlobalInsightServiceOptions[] = [] closeCalls = 0 + getAnnualSummary(options: GlobalInsightServiceOptions = {}) { this.getCalls.push(options) return emptyResponse() } + startRecompute(options: GlobalInsightServiceOptions = {}) { this.recomputeCalls.push(options) return emptyResponse() } + invalidateCache() { // Not used by route contract tests. } + normalizeRange() { return emptyResponse().range } + replaceSnapshotForTests() { // Not used by route contract tests. } + async close() { this.closeCalls++ } } -class FakeTimeInvestmentService implements TimeInvestmentService { - getCalls: TimeInvestmentServiceOptions[] = [] - recomputeCalls: TimeInvestmentServiceOptions[] = [] - closeCalls = 0 - getTimeInvestment(options: TimeInvestmentServiceOptions = {}) { - this.getCalls.push(options) - return emptyTimeInvestmentResponse() - } - startRecompute(options: TimeInvestmentServiceOptions = {}) { - this.recomputeCalls.push(options) - return emptyTimeInvestmentResponse() - } - async close() { - this.closeCalls++ - } -} - -function context( - service: GlobalInsightService, - timeInvestmentService: TimeInvestmentService = new FakeTimeInvestmentService() -): GlobalInsightRouteContext { +function context(service: GlobalInsightService): NodePluginContext { return { globalInsightService: service, - timeInvestmentService, sessionAdapter: {} as SessionRuntimeAdapter, pathProvider: {} as PathProvider, } } -test('GET forwards year mode and stale preference', async (t) => { +test('annual summary plugin forwards year mode and stale preference', async (t) => { const service = new FakeService() const app = Fastify() t.after(() => app.close()) - registerGlobalInsightRoutes(app, context(service)) + registerNodePlugins(app, context(service), [annualSummaryNodePlugin]) await app.ready() const response = await app.inject({ @@ -133,34 +98,11 @@ test('GET forwards year mode and stale preference', async (t) => { assert.deepEqual(response.json().monthlyDirectContacts, []) }) -test('time investment routes forward range, stale, and recompute options', async (t) => { - const service = new FakeService() - const timeInvestmentService = new FakeTimeInvestmentService() - const app = Fastify() - t.after(() => app.close()) - registerGlobalInsightRoutes(app, context(service, timeInvestmentService)) - await app.ready() - - const getResponse = await app.inject({ - method: 'GET', - url: '/_web/global-insight/time-investment?mode=year&year=2025&acceptStale=true', - }) - const recomputeResponse = await app.inject({ - method: 'POST', - url: '/_web/global-insight/time-investment/recompute?mode=recent&days=365', - }) - - assert.equal(getResponse.statusCode, 200) - assert.equal(recomputeResponse.statusCode, 200) - assert.deepEqual(timeInvestmentService.getCalls, [{ mode: 'year', year: 2025, days: undefined, acceptStale: true }]) - assert.deepEqual(timeInvestmentService.recomputeCalls, [{ mode: 'recent', year: undefined, days: 365 }]) -}) - -test('GET normalizes unsupported values before calling the service', async (t) => { +test('annual summary plugin normalizes unsupported values before calling the service', async (t) => { const service = new FakeService() const app = Fastify() t.after(() => app.close()) - registerGlobalInsightRoutes(app, context(service)) + registerNodePlugins(app, context(service), [annualSummaryNodePlugin]) await app.ready() await app.inject({ method: 'GET', url: '/_web/global-insight/annual-summary?mode=other&year=nope&days=2' }) @@ -168,11 +110,10 @@ test('GET normalizes unsupported values before calling the service', async (t) = assert.deepEqual(service.getCalls, [{ mode: 'year', year: undefined, days: undefined, acceptStale: false }]) }) -test('POST recompute forwards recent mode and closes the injected service', async () => { +test('annual summary plugin forwards recompute and owns its service lifecycle', async () => { const service = new FakeService() - const timeInvestmentService = new FakeTimeInvestmentService() const app = Fastify() - registerGlobalInsightRoutes(app, context(service, timeInvestmentService)) + registerNodePlugins(app, context(service), [annualSummaryNodePlugin]) await app.ready() const response = await app.inject({ @@ -184,5 +125,18 @@ test('POST recompute forwards recent mode and closes the injected service', asyn assert.deepEqual(service.recomputeCalls, [{ mode: 'recent', year: undefined, days: 365 }]) await app.close() assert.equal(service.closeCalls, 1) - assert.equal(timeInvestmentService.closeCalls, 1) +}) + +test('omitting the annual summary plugin leaves its API unavailable', async (t) => { + const service = new FakeService() + const app = Fastify() + t.after(() => app.close()) + registerNodePlugins(app, context(service), []) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/_web/global-insight/annual-summary' }) + + assert.equal(response.statusCode, 404) + assert.equal(service.getCalls.length, 0) + assert.equal(service.closeCalls, 0) }) diff --git a/packages/http-routes/src/plugins/builtin/annual-summary/index.ts b/packages/http-routes/src/plugins/builtin/annual-summary/index.ts new file mode 100644 index 00000000..c667d6c9 --- /dev/null +++ b/packages/http-routes/src/plugins/builtin/annual-summary/index.ts @@ -0,0 +1,60 @@ +import { ANNUAL_SUMMARY_PLUGIN_ID } from '@openchatlab/shared-types' +import { createGlobalInsightService, PreferencesManager } from '@openchatlab/node-runtime' +import type { NodePluginDescriptor } from '../../node' + +interface AnnualSummaryQuery { + mode?: string + year?: string + days?: string + acceptStale?: string +} + +export const annualSummaryNodePlugin = { + id: ANNUAL_SUMMARY_PLUGIN_ID, + registerHttpRoutes(server, context) { + let preferences = context.preferencesManager + const getExcludedSessionIds = () => { + preferences ??= new PreferencesManager(context.pathProvider.getSystemDir()) + return preferences.load().ownerExcludedSessionIds + } + const service = + context.globalInsightService ?? + createGlobalInsightService({ + adapter: context.sessionAdapter, + pathProvider: context.pathProvider, + runtimeIdentity: context.runtimeIdentity, + nativeBinding: context.nativeBinding, + getExcludedSessionIds, + }) + + server.addHook('onClose', () => service.close()) + server.get<{ Querystring: AnnualSummaryQuery }>('/_web/global-insight/annual-summary', async (request) => + service.getAnnualSummary({ + ...parseRange(request.query), + acceptStale: isTruthy(request.query.acceptStale), + }) + ) + server.post<{ Querystring: AnnualSummaryQuery }>('/_web/global-insight/annual-summary/recompute', async (request) => + service.startRecompute(parseRange(request.query)) + ) + }, +} satisfies NodePluginDescriptor + +function parseRange(query: AnnualSummaryQuery) { + const mode = query.mode === 'recent' ? ('recent' as const) : ('year' as const) + return { + mode, + year: parseInteger(query.year), + days: query.days === '365' ? (365 as const) : undefined, + } +} + +function parseInteger(value: string | undefined): number | undefined { + if (!value) return undefined + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : undefined +} + +function isTruthy(value: string | undefined): boolean { + return value === '1' || value === 'true' || value === 'yes' +} diff --git a/packages/http-routes/src/plugins/node.ts b/packages/http-routes/src/plugins/node.ts new file mode 100644 index 00000000..56888dbd --- /dev/null +++ b/packages/http-routes/src/plugins/node.ts @@ -0,0 +1,28 @@ +import type { FastifyInstance } from 'fastify' +import type { RuntimeRouteContext } from '../context/runtime' +import type { ServiceRouteContext } from '../context/services' + +export type NodePluginContext = Pick< + RuntimeRouteContext, + 'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding' +> & + ServiceRouteContext + +/** + * Static, trusted Node-side plugin facet. + * + * Route registration happens only during server startup. Fastify owns the + * resulting route and service lifecycle until the server closes. + */ +export interface NodePluginDescriptor { + readonly id: string + registerHttpRoutes(server: FastifyInstance, context: NodePluginContext): void +} + +export function registerNodePlugins( + server: FastifyInstance, + context: NodePluginContext, + plugins: readonly NodePluginDescriptor[] +): void { + for (const plugin of plugins) plugin.registerHttpRoutes(server, context) +} diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index dbce4487..2037aee3 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -230,6 +230,11 @@ describe('registerSharedRoutes smoke tests', () => { assert.ok(Array.isArray(resp.json())) }) + it('registers host insights without implicitly enabling optional Node plugins', () => { + assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/time-investment' }), true) + assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/annual-summary' }), false) + }) + it('GET /_web/sessions reuses a fingerprint-validated overview cache', async () => { const db = createSessionDb() const routeApp = Fastify() diff --git a/packages/http-routes/src/routes/web/global-insight.ts b/packages/http-routes/src/routes/web/global-insight.ts deleted file mode 100644 index c1b453de..00000000 --- a/packages/http-routes/src/routes/web/global-insight.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import { createGlobalInsightService, createTimeInvestmentService, PreferencesManager } from '@openchatlab/node-runtime' -import type { RuntimeRouteContext } from '../../context/runtime' -import type { ServiceRouteContext } from '../../context/services' - -interface GlobalInsightQuery { - mode?: string - year?: string - days?: string - acceptStale?: string -} - -type GlobalInsightRouteContext = Pick< - RuntimeRouteContext, - 'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding' -> & - Pick - -export function registerGlobalInsightRoutes(server: FastifyInstance, ctx: GlobalInsightRouteContext): void { - let preferences = ctx.preferencesManager - const getExcludedSessionIds = () => { - preferences ??= new PreferencesManager(ctx.pathProvider.getSystemDir()) - return preferences.load().ownerExcludedSessionIds - } - const service = - ctx.globalInsightService ?? - createGlobalInsightService({ - adapter: ctx.sessionAdapter, - pathProvider: ctx.pathProvider, - runtimeIdentity: ctx.runtimeIdentity, - nativeBinding: ctx.nativeBinding, - getExcludedSessionIds, - }) - const timeInvestmentService = - ctx.timeInvestmentService ?? - createTimeInvestmentService({ - adapter: ctx.sessionAdapter, - pathProvider: ctx.pathProvider, - runtimeIdentity: ctx.runtimeIdentity, - nativeBinding: ctx.nativeBinding, - getExcludedSessionIds, - }) - server.addHook('onClose', async () => { - await Promise.all([service.close(), timeInvestmentService.close()]) - }) - - server.get<{ Querystring: GlobalInsightQuery }>('/_web/global-insight/annual-summary', async (request) => { - return service.getAnnualSummary({ - ...parseRange(request.query), - acceptStale: isTruthy(request.query.acceptStale), - }) - }) - - server.post<{ Querystring: GlobalInsightQuery }>('/_web/global-insight/annual-summary/recompute', async (request) => - service.startRecompute(parseRange(request.query)) - ) - - server.get<{ Querystring: GlobalInsightQuery }>('/_web/global-insight/time-investment', async (request) => - timeInvestmentService.getTimeInvestment({ - ...parseRange(request.query), - acceptStale: isTruthy(request.query.acceptStale), - }) - ) - - server.post<{ Querystring: GlobalInsightQuery }>('/_web/global-insight/time-investment/recompute', async (request) => - timeInvestmentService.startRecompute(parseRange(request.query)) - ) -} - -function parseRange(query: GlobalInsightQuery) { - const mode = query.mode === 'recent' ? ('recent' as const) : ('year' as const) - return { - mode, - year: parseInteger(query.year), - days: query.days === '365' ? (365 as const) : undefined, - } -} - -function parseInteger(value: string | undefined): number | undefined { - if (!value) return undefined - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : undefined -} - -function isTruthy(value: string | undefined): boolean { - return value === '1' || value === 'true' || value === 'yes' -} diff --git a/packages/http-routes/src/routes/web/register.ts b/packages/http-routes/src/routes/web/register.ts index 2f7c3480..b6ca9b40 100644 --- a/packages/http-routes/src/routes/web/register.ts +++ b/packages/http-routes/src/routes/web/register.ts @@ -5,12 +5,13 @@ import type { MergeRouteContext } from '../../context/merge' import type { RuntimeRouteContext } from '../../context/runtime' import type { ServiceRouteContext } from '../../context/services' import type { StorageRouteContext } from '../../context/storage' +import { registerNodePlugins, type NodePluginDescriptor } from '../../plugins/node' import { registerAiRoutes, type AiRouteOptions } from './register-ai' import { registerAnalyticsRoutes } from './analytics' import { registerCacheRoutes } from './cache' import { registerContactsRoutes } from './contacts' import { registerExportRoutes } from './export' -import { registerGlobalInsightRoutes } from './global-insight' +import { registerTimeInvestmentRoutes } from './time-investment' import { registerLogRoutes } from './logs' import { registerMemberRoutes } from './members' import { registerMergeRoutes } from './merge' @@ -28,7 +29,10 @@ export type WebRoutesContext = RuntimeRouteContext & AiRouteContext & StorageRouteContext -export type WebRouteOptions = AiRouteOptions +export interface WebRouteOptions extends AiRouteOptions { + /** Trusted Node plugin facets selected by the platform's static catalog. */ + nodePlugins?: readonly NodePluginDescriptor[] +} /** Register the internal Web API under /_web, excluding lifecycle-owned automation routes. */ export function registerWebRoutes(server: FastifyInstance, ctx: WebRoutesContext, options?: WebRouteOptions): void { @@ -41,7 +45,8 @@ export function registerWebRoutes(server: FastifyInstance, ctx: WebRoutesContext registerMemberRoutes(server, resolvedCtx) registerContactsRoutes(server, resolvedCtx) registerPeopleRelationshipsRoutes(server, resolvedCtx) - registerGlobalInsightRoutes(server, resolvedCtx) + registerTimeInvestmentRoutes(server, resolvedCtx) + registerNodePlugins(server, resolvedCtx, options?.nodePlugins ?? []) registerPreferencesRoutes(server, resolvedCtx) registerAnalyticsRoutes(server, resolvedCtx) registerSqlRoutes(server, resolvedCtx) diff --git a/packages/http-routes/src/routes/web/time-investment.test.ts b/packages/http-routes/src/routes/web/time-investment.test.ts new file mode 100644 index 00000000..6211db91 --- /dev/null +++ b/packages/http-routes/src/routes/web/time-investment.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import Fastify from 'fastify' +import type { + SessionRuntimeAdapter, + TimeInvestmentService, + TimeInvestmentServiceOptions, +} from '@openchatlab/node-runtime' +import type { TimeInvestmentResponse } from '@openchatlab/shared-types' +import type { PathProvider } from '@openchatlab/core' +import { registerTimeInvestmentRoutes } from './time-investment' + +type TimeInvestmentRouteContext = Parameters[1] + +function emptyTimeInvestmentResponse(): TimeInvestmentResponse { + return { + range: { mode: 'year', year: 2026, startTs: 1, endTs: 2 }, + availableDataYears: [], + latestDataYear: null, + metrics: null, + monthlyActivity: [], + dailyActivity: [], + sessionRanking: [], + chatTypes: [], + coverage: { + totalSessions: 0, + analyzedSessions: 0, + missingOwnerSessions: 0, + unresolvedOwnerSessions: 0, + failedSessions: 0, + }, + cache: { status: 'missing', computedAt: null }, + task: { + id: null, + status: 'idle', + startedAt: null, + finishedAt: null, + processedSessions: 0, + totalSessions: 0, + }, + } +} + +class FakeTimeInvestmentService implements TimeInvestmentService { + getCalls: TimeInvestmentServiceOptions[] = [] + recomputeCalls: TimeInvestmentServiceOptions[] = [] + closeCalls = 0 + getTimeInvestment(options: TimeInvestmentServiceOptions = {}) { + this.getCalls.push(options) + return emptyTimeInvestmentResponse() + } + startRecompute(options: TimeInvestmentServiceOptions = {}) { + this.recomputeCalls.push(options) + return emptyTimeInvestmentResponse() + } + async close() { + this.closeCalls++ + } +} + +function timeInvestmentContext(service: TimeInvestmentService): TimeInvestmentRouteContext { + return { + timeInvestmentService: service, + sessionAdapter: {} as SessionRuntimeAdapter, + pathProvider: {} as PathProvider, + } +} + +test('time investment routes forward range, stale, recompute, and close the service', async () => { + const timeInvestmentService = new FakeTimeInvestmentService() + const app = Fastify() + registerTimeInvestmentRoutes(app, timeInvestmentContext(timeInvestmentService)) + await app.ready() + + const getResponse = await app.inject({ + method: 'GET', + url: '/_web/global-insight/time-investment?mode=year&year=2025&acceptStale=true', + }) + const recomputeResponse = await app.inject({ + method: 'POST', + url: '/_web/global-insight/time-investment/recompute?mode=recent&days=365', + }) + + assert.equal(getResponse.statusCode, 200) + assert.equal(recomputeResponse.statusCode, 200) + assert.deepEqual(timeInvestmentService.getCalls, [{ mode: 'year', year: 2025, days: undefined, acceptStale: true }]) + assert.deepEqual(timeInvestmentService.recomputeCalls, [{ mode: 'recent', year: undefined, days: 365 }]) + await app.close() + assert.equal(timeInvestmentService.closeCalls, 1) +}) diff --git a/packages/http-routes/src/routes/web/time-investment.ts b/packages/http-routes/src/routes/web/time-investment.ts new file mode 100644 index 00000000..faf8b936 --- /dev/null +++ b/packages/http-routes/src/routes/web/time-investment.ts @@ -0,0 +1,65 @@ +import type { FastifyInstance } from 'fastify' +import { createTimeInvestmentService, PreferencesManager } from '@openchatlab/node-runtime' +import type { RuntimeRouteContext } from '../../context/runtime' +import type { ServiceRouteContext } from '../../context/services' + +interface TimeInvestmentQuery { + mode?: string + year?: string + days?: string + acceptStale?: string +} + +type TimeInvestmentRouteContext = Pick< + RuntimeRouteContext, + 'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding' +> & + Pick + +export function registerTimeInvestmentRoutes(server: FastifyInstance, ctx: TimeInvestmentRouteContext): void { + let preferences = ctx.preferencesManager + const getExcludedSessionIds = () => { + preferences ??= new PreferencesManager(ctx.pathProvider.getSystemDir()) + return preferences.load().ownerExcludedSessionIds + } + const service = + ctx.timeInvestmentService ?? + createTimeInvestmentService({ + adapter: ctx.sessionAdapter, + pathProvider: ctx.pathProvider, + runtimeIdentity: ctx.runtimeIdentity, + nativeBinding: ctx.nativeBinding, + getExcludedSessionIds, + }) + server.addHook('onClose', () => service.close()) + + server.get<{ Querystring: TimeInvestmentQuery }>('/_web/global-insight/time-investment', async (request) => + service.getTimeInvestment({ + ...parseRange(request.query), + acceptStale: isTruthy(request.query.acceptStale), + }) + ) + + server.post<{ Querystring: TimeInvestmentQuery }>('/_web/global-insight/time-investment/recompute', async (request) => + service.startRecompute(parseRange(request.query)) + ) +} + +function parseRange(query: TimeInvestmentQuery) { + const mode = query.mode === 'recent' ? ('recent' as const) : ('year' as const) + return { + mode, + year: parseInteger(query.year), + days: query.days === '365' ? (365 as const) : undefined, + } +} + +function parseInteger(value: string | undefined): number | undefined { + if (!value) return undefined + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : undefined +} + +function isTruthy(value: string | undefined): boolean { + return value === '1' || value === 'true' || value === 'yes' +} diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 951da7f3..1085d537 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -3,6 +3,11 @@ * 平台无关的共享类型定义,三端(Electron / Node 服务 / Web)统一使用 */ +// ==================== Plugin identity ==================== + +/** Stable identity shared by the UI and Node facets of the built-in plugin. */ +export const ANNUAL_SUMMARY_PLUGIN_ID = 'chatlab.insight.annual-summary' + // ==================== 时间筛选 ==================== export interface TimeFilter { diff --git a/src/bootstrap/mount-app.ts b/src/bootstrap/mount-app.ts index 6d639f59..2856ce70 100644 --- a/src/bootstrap/mount-app.ts +++ b/src/bootstrap/mount-app.ts @@ -8,6 +8,14 @@ import i18n from '@/i18n' import { backendPersistPlugin } from '@/plugins/backendPersist' import { installGlobalErrorReporting, reportError } from '@/services/log-report' import { markStartupPhase } from '@/bootstrap/startup-performance' +import { + desktopCliWebInsightBuiltins, + desktopCliWebInsightRuntime, + desktopCliWebUiHost, + desktopCliWebUiServices, +} from '@/plugins/desktop-cli-web' +import { installInsightPluginRuntime } from '@/plugins/insight-vue' +import { installStaticInsightPluginUiServices } from '@/plugins/static-insight' import '@/assets/styles/main.css' export interface MountChatLabAppOptions { @@ -33,6 +41,16 @@ export async function mountChatLabApp(options: MountChatLabAppOptions = {}): Pro app.use(router) app.use(ui) app.use(i18n) + await installStaticInsightPluginUiServices( + desktopCliWebInsightBuiltins, + desktopCliWebInsightRuntime, + desktopCliWebUiServices + ) + app.onUnmount(() => { + desktopCliWebInsightRuntime.disposeAll() + desktopCliWebUiHost.locale.dispose() + }) + installInsightPluginRuntime(app, desktopCliWebInsightRuntime) markStartupPhase('vue-mount-start') app.mount('#app') markStartupPhase('vue-mounted') diff --git a/src/i18n/locales/en-US/insight.json b/src/i18n/locales/en-US/insight.json index 96f62065..b9f13d44 100644 --- a/src/i18n/locales/en-US/insight.json +++ b/src/i18n/locales/en-US/insight.json @@ -3,59 +3,13 @@ "reserved": "This view will be available in a future release", "tabs": { "nav": "Footprint views", - "annualSummary": "About Me", "timeInvestment": "Time Investment", "relationshipChanges": "Relationship Changes" }, - "messages": "Messages", "monthLabel": "Month {month}", "noData": "No analyzable data in this time range", - "noTextData": "No text messages in this time range", - "calendar": { - "less": "Less", - "more": "More" - }, - "kpis": { - "messages": "Messages sent", - "activeDays": "Active days", - "contacts": "Direct contacts", - "dailyMessages": "Messages per day", - "dailyContacts": "Contacts per day" - }, "overviewCard": { - "yearTitle": "Me in {year}", - "recentTitle": "My Last 12 Months", - "narrative": { - "messagesPrefix": "Over the year, you sent", - "messagesSuffix": "messages, averaging", - "dailySuffix": "per day.", - "activePrefix": "You left messages on", - "activeMiddle": "days and directly interacted with", - "contactsSuffix": "people." - }, - "activity": "Activity Calendar", - "activeRate": "{rate}% of days", - "keyMetrics": "Key Metrics", - "perDay": "Daily average", - "messagesCount": "{count} messages", - "characters": "{count} chars", - "textLengthDetail": "Text message length", - "dailyContacts": "Contacts per day", - "peakMonth": "Most active month", - "peakDay": "Most active day", - "longestActiveStreak": "Longest active streak", - "consecutiveActiveDays": "Consecutive days with sent messages", - "topWeekday": "Most active weekday", - "mostMessagesSent": "Most messages sent", - "weekendMessageRate": "Weekend message share", - "weekdayMessageRate": "Weekdays {rate}%", - "monthlyTrend": { - "messages": "Messages", - "contacts": "Direct contacts" - }, - "textRatio": "Text message share", - "medianLength": "Median text length", - "p90Length": "P90 text length" + "keyMetrics": "Key Metrics" }, "timeInvestment": { "yearTitle": "Time invested in {year}", @@ -96,27 +50,9 @@ "hoursMinutes": "{hours} hr {minutes} min" } }, - "sections": { - "overview": "Annual overview", - "overviewDescription": "Non-system messages I sent each month; missing months count as zero", - "directContactsDescription": "Unique people who directly interacted with me each month; missing months count as zero", - "activity": "Activity rhythm", - "activityDescription": "Daily sent messages; darker days were more active", - "messageTypes": "Message types", - "messageTypesDescription": "Only messages sent by me", - "textLength": "Text length", - "textLengthDescription": "Character counts for non-empty text messages only", - "habitsTitle": "Expression Habits", - "habitsDescription": "The composition of message types and character length distribution of text messages I sent" - }, - "length": { - "median": "Median" - }, "status": { - "loading": "Loading annual summary…", "computingProgress": "Analyzing conversations {processed}/{total}…", "updating": "Data changed; this result is being refreshed in the background", - "failed": "Annual summary calculation failed. You can retry it.", "coverage": "Analyzed {analyzed} of {total} conversations", "ownerIssues": "{count} conversations still need a valid “me” identity", "noAnalyzableOwner": "No conversation has a usable identity yet. Set who “me” is in a conversation first.", @@ -124,7 +60,6 @@ }, "actions": { "retry": "Retry", - "openSessions": "Open conversations", "switchYear": "View {year}" } } diff --git a/src/i18n/locales/ja-JP/insight.json b/src/i18n/locales/ja-JP/insight.json index c960ba65..a5c74f3f 100644 --- a/src/i18n/locales/ja-JP/insight.json +++ b/src/i18n/locales/ja-JP/insight.json @@ -3,59 +3,13 @@ "reserved": "このビューは今後のバージョンで公開予定です", "tabs": { "nav": "足跡ビュー", - "annualSummary": "私について", "timeInvestment": "時間の投入", "relationshipChanges": "関係の変化" }, - "messages": "メッセージ数", "monthLabel": "{month}月", "noData": "この期間には分析可能なデータがありません", - "noTextData": "この期間にはテキストメッセージがありません", - "calendar": { - "less": "少ない", - "more": "多い" - }, - "kpis": { - "messages": "送信メッセージ", - "activeDays": "アクティブ日数", - "contacts": "直接交流した人数", - "dailyMessages": "1日平均メッセージ", - "dailyContacts": "1日平均交流人数" - }, "overviewCard": { - "yearTitle": "{year} 年の私", - "recentTitle": "直近1年間の私", - "narrative": { - "messagesPrefix": "この一年で", - "messagesSuffix": "件のメッセージを送り、1日平均", - "dailySuffix": "件でした。", - "activePrefix": "メッセージを送った日は", - "activeMiddle": "日で、直接交流した相手は", - "contactsSuffix": "人でした。" - }, - "activity": "アクティビティカレンダー", - "activeRate": "日数の {rate}%", - "keyMetrics": "主要指標", - "perDay": "1日平均", - "messagesCount": "{count} 件のメッセージ", - "characters": "{count} 文字", - "textLengthDetail": "テキストメッセージの文字数", - "dailyContacts": "1日平均交流人数", - "peakMonth": "最も活発な月", - "peakDay": "最も活発な日", - "longestActiveStreak": "最長連続アクティブ", - "consecutiveActiveDays": "連続してメッセージを送信した日数", - "topWeekday": "最も活発な曜日", - "mostMessagesSent": "送信数が最多", - "weekendMessageRate": "週末メッセージ比率", - "weekdayMessageRate": "平日 {rate}%", - "monthlyTrend": { - "messages": "メッセージ数", - "contacts": "交流人数" - }, - "textRatio": "テキストメッセージ比率", - "medianLength": "テキスト長の中央値", - "p90Length": "テキスト長 P90" + "keyMetrics": "主要指標" }, "timeInvestment": { "yearTitle": "{year} 年の時間投入", @@ -96,27 +50,9 @@ "hoursMinutes": "{hours}時間{minutes}分" } }, - "sections": { - "overview": "年間概要", - "overviewDescription": "自分が送信したシステム以外のメッセージを月別に集計します", - "directContactsDescription": "自分と直接交流した人数を月ごとに重複を除いて集計します", - "activity": "活動リズム", - "activityDescription": "日別の送信数。色が濃いほど活発な日です", - "messageTypes": "メッセージタイプ", - "messageTypesDescription": "自分が送信したメッセージのみを集計します", - "textLength": "テキスト長の分布", - "textLengthDescription": "空でないテキストメッセージの文字数のみを集计します", - "habitsTitle": "表現習慣", - "habitsDescription": "自分が送信したメッセージタイプの構成とテキスト文字数分布" - }, - "length": { - "median": "中央値" - }, "status": { - "loading": "年間まとめを読み込んでいます…", "computingProgress": "会話を分析しています {processed}/{total}…", "updating": "データが変更されたため、バックグラウンドで更新しています", - "failed": "年間まとめの計算に失敗しました。再試行できます。", "coverage": "{total} 件中 {analyzed} 件の会話を分析済み", "ownerIssues": "{count} 件の会話で「自分」の設定が必要です", "noAnalyzableOwner": "足跡の分析に使用できる本人情報がありません。先に会話で「自分」を設定してください。", @@ -124,7 +60,6 @@ }, "actions": { "retry": "再試行", - "openSessions": "会話一覧を開く", "switchYear": "{year} 年を見る" } } diff --git a/src/i18n/locales/zh-CN/insight.json b/src/i18n/locales/zh-CN/insight.json index c6998398..073c121d 100644 --- a/src/i18n/locales/zh-CN/insight.json +++ b/src/i18n/locales/zh-CN/insight.json @@ -3,59 +3,13 @@ "reserved": "该视图将在后续版本开放", "tabs": { "nav": "足迹视图", - "annualSummary": "关于我", "timeInvestment": "时间投入", "relationshipChanges": "关系变化" }, - "messages": "消息数", "monthLabel": "{month}月", "noData": "当前时间范围内暂无可分析数据", - "noTextData": "当前时间范围内没有文本消息", - "calendar": { - "less": "少", - "more": "多" - }, - "kpis": { - "messages": "发出消息", - "activeDays": "活跃天数", - "contacts": "直接互动人数", - "dailyMessages": "日均消息", - "dailyContacts": "日均互动人数" - }, "overviewCard": { - "yearTitle": "{year} 年的我", - "recentTitle": "最近一年的我", - "narrative": { - "messagesPrefix": "这一年,你发出了", - "messagesSuffix": "条消息,平均每天", - "dailySuffix": "条。", - "activePrefix": "有", - "activeMiddle": "天留下了你的消息,并与", - "contactsSuffix": "人有过直接互动。" - }, - "activity": "活跃日历", - "activeRate": "覆盖 {rate}% 日期", - "keyMetrics": "关键指标", - "perDay": "平均每天", - "messagesCount": "{count} 条消息", - "characters": "{count} 字", - "textLengthDetail": "文本消息字符数", - "dailyContacts": "日均互动人数", - "peakMonth": "最活跃月份", - "peakDay": "最活跃日期", - "longestActiveStreak": "最长连续活跃", - "consecutiveActiveDays": "连续发出消息的天数", - "topWeekday": "最常活跃星期", - "mostMessagesSent": "发出消息最多", - "weekendMessageRate": "周末消息占比", - "weekdayMessageRate": "工作日 {rate}%", - "monthlyTrend": { - "messages": "消息量", - "contacts": "互动人数" - }, - "textRatio": "文字消息占比", - "medianLength": "文本长度中位数", - "p90Length": "文本长度 P90" + "keyMetrics": "关键指标" }, "timeInvestment": { "yearTitle": "{year} 年的时间投入", @@ -96,27 +50,9 @@ "hoursMinutes": "{hours} 小时 {minutes} 分" } }, - "sections": { - "overview": "年度概览", - "overviewDescription": "按月统计我发出的非系统消息,缺失月份按零计算", - "directContactsDescription": "按月统计与我发生直接互动的去重人数,缺失月份按零计算", - "activity": "活跃节奏", - "activityDescription": "每天发出的消息数量,颜色越深代表当天越活跃", - "messageTypes": "消息类型构成", - "messageTypesDescription": "仅统计我发出的消息", - "textLength": "文本长度分布", - "textLengthDescription": "仅统计非空文本消息的字符数", - "habitsTitle": "表达习惯", - "habitsDescription": "我发送的消息类型构成与文本字符长度分布" - }, - "length": { - "median": "中位数" - }, "status": { - "loading": "正在读取年度总结…", "computingProgress": "正在分析会话 {processed}/{total}…", "updating": "数据发生变化,正在后台更新当前结果", - "failed": "年度总结计算失败,可以重试", "coverage": "已分析 {analyzed}/{total} 个会话", "ownerIssues": "{count} 个会话尚未正确设置“我是谁”", "noAnalyzableOwner": "还没有可用于足迹分析的身份,请先在会话中设置“我是谁”", @@ -124,7 +60,6 @@ }, "actions": { "retry": "重试", - "openSessions": "前往会话列表", "switchYear": "查看 {year} 年" } } diff --git a/src/i18n/locales/zh-TW/insight.json b/src/i18n/locales/zh-TW/insight.json index 4f0d63b4..0760f7b2 100644 --- a/src/i18n/locales/zh-TW/insight.json +++ b/src/i18n/locales/zh-TW/insight.json @@ -3,59 +3,13 @@ "reserved": "此檢視將於後續版本開放", "tabs": { "nav": "足跡檢視", - "annualSummary": "關於我", "timeInvestment": "時間投入", "relationshipChanges": "關係變化" }, - "messages": "訊息數", "monthLabel": "{month}月", "noData": "目前時間範圍內沒有可分析資料", - "noTextData": "目前時間範圍內沒有文字訊息", - "calendar": { - "less": "少", - "more": "多" - }, - "kpis": { - "messages": "發出訊息", - "activeDays": "活躍天數", - "contacts": "直接互動人數", - "dailyMessages": "日均訊息", - "dailyContacts": "日均互動人數" - }, "overviewCard": { - "yearTitle": "{year} 年的我", - "recentTitle": "最近一年的我", - "narrative": { - "messagesPrefix": "這一年,你發出了", - "messagesSuffix": "條訊息,平均每天", - "dailySuffix": "條。", - "activePrefix": "有", - "activeMiddle": "天留下了你的訊息,並與", - "contactsSuffix": "人有過直接互動。" - }, - "activity": "活躍日曆", - "activeRate": "涵蓋 {rate}% 日期", - "keyMetrics": "關鍵指標", - "perDay": "平均每天", - "messagesCount": "{count} 條訊息", - "characters": "{count} 字", - "textLengthDetail": "文字訊息字元數", - "dailyContacts": "日均互動人數", - "peakMonth": "最活躍月份", - "peakDay": "最活躍日期", - "longestActiveStreak": "最長連續活躍", - "consecutiveActiveDays": "連續發出訊息的天數", - "topWeekday": "最常活躍星期", - "mostMessagesSent": "發出訊息最多", - "weekendMessageRate": "週末訊息佔比", - "weekdayMessageRate": "工作日 {rate}%", - "monthlyTrend": { - "messages": "訊息量", - "contacts": "互動人數" - }, - "textRatio": "文字訊息占比", - "medianLength": "文字長度中位數", - "p90Length": "文字長度 P90" + "keyMetrics": "關鍵指標" }, "timeInvestment": { "yearTitle": "{year} 年的時間投入", @@ -96,27 +50,9 @@ "hoursMinutes": "{hours} 小時 {minutes} 分" } }, - "sections": { - "overview": "年度概覽", - "overviewDescription": "按月統計我發出的非系統訊息,缺少月份以零計算", - "directContactsDescription": "按月統計與我直接互動的去重人數,缺少月份以零計算", - "activity": "活躍節奏", - "activityDescription": "每天發出的訊息數量,顏色越深代表當天越活躍", - "messageTypes": "訊息類型構成", - "messageTypesDescription": "只統計我發出的訊息", - "textLength": "文字長度分佈", - "textLengthDescription": "只統計非空文字訊息的字元數", - "habitsTitle": "表達習慣", - "habitsDescription": "我發送的訊息類型構成與文字字元長度分佈" - }, - "length": { - "median": "中位數" - }, "status": { - "loading": "正在讀取年度總結…", "computingProgress": "正在分析會話 {processed}/{total}…", "updating": "資料已變更,正在背景更新目前結果", - "failed": "年度總結計算失敗,可以重試", "coverage": "已分析 {analyzed}/{total} 個會話", "ownerIssues": "{count} 個會話尚未正確設定「我是誰」", "noAnalyzableOwner": "尚無可用於足跡分析的身分,請先在會話中設定「我是誰」", @@ -124,7 +60,6 @@ }, "actions": { "retry": "重試", - "openSessions": "前往會話列表", "switchYear": "查看 {year} 年" } } diff --git a/src/pages/insight/index.vue b/src/pages/insight/index.vue index 76d9c1ec..6ea53166 100644 --- a/src/pages/insight/index.vue +++ b/src/pages/insight/index.vue @@ -1,47 +1,79 @@ @@ -62,13 +94,13 @@ const navigationItems = computed(() => { :items="navigationItems" :aria-label="t('insight.tabs.nav')" > -