From b1cf871b2549a4384a668b8856417e843be1a496 Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 17:08:31 +0800 Subject: [PATCH 01/10] feat(desktop): collect product analytics through OTel --- apps/desktop-host/src/index.ts | 1 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/package.json | 3 +- apps/desktop/src/client/WelcomePage.tsx | 14 +- apps/desktop/src/main.ts | 19 ++- apps/desktop/src/preload-welcome.ts | 1 + apps/desktop/src/update-coordinator.ts | 4 + apps/desktop/src/welcome-api.ts | 5 + apps/desktop/src/welcome-backend.ts | 11 +- apps/desktop/src/welcome-window.ts | 19 ++- apps/desktop/tests/main-startup.spec.ts | 21 ++- apps/desktop/tests/update-coordinator.spec.ts | 14 +- apps/desktop/tests/welcome-host.spec.ts | 14 ++ .../tests/welcome-renderer.client.spec.tsx | 14 ++ apps/desktop/tsconfig.host.json | 3 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 + docs/capability-seams.zh.md | 4 + docs/config-catalog.md | 19 +++ docs/config-catalog.zh.md | 19 +++ docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + docs/subsystems/product-telemetry.i18n.yaml | 4 +- docs/subsystems/product-telemetry.md | 25 +++- docs/subsystems/product-telemetry.zh.md | 25 +++- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 + packages/api/remotes/README.zh.md | 2 + packages/api/remotes/package.json | 3 +- packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/tsconfig.client.json | 1 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../src/client/contract/sessions.ts | 9 +- .../src/client/sessions/service.ts | 2 + .../tests/sessions-service.client.spec.ts | 4 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 + packages/bundle/web-app/README.zh.md | 2 + packages/bundle/web-app/cordis.patch.yml | 14 ++ packages/bundle/web-app/package.json | 4 +- .../client/product-analytics/README.i18n.yaml | 34 +++++ packages/client/product-analytics/README.md | 65 ++++++++++ .../client/product-analytics/README.zh.md | 65 ++++++++++ .../client/product-analytics/package.json | 80 ++++++++++++ .../product-analytics/src/client/index.ts | 44 +++++++ .../client/product-analytics/src/events.ts | 64 +++++++++ .../client/product-analytics/src/index.ts | 86 +++++++++++++ .../tests/collection.client.spec.ts | 54 ++++++++ .../tests/collection.host.spec.ts | 121 ++++++++++++++++++ .../product-analytics/tsconfig.client.json | 26 ++++ .../product-analytics/tsconfig.host.json | 44 +++++++ .../client/product-analytics/tsconfig.json | 11 ++ .../client/product-analytics/tsdown.config.ts | 3 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + packages/client/ui-chat/package.json | 3 +- packages/client/ui-chat/src/client/apply.ts | 7 +- .../src/client/chat/TurnTailNodeView.tsx | 2 +- .../ui-chat/src/client/contract/slots.ts | 4 +- .../tests/apply-inject.client.spec.tsx | 6 +- .../ui-chat/tests/chat-view.client.spec.tsx | 4 +- packages/client/ui-chat/tsconfig.client.json | 3 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + packages/client/ui-conversation/package.json | 3 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/draft-editor.ts | 2 +- .../src/client/contract/input.ts | 2 +- .../src/client/input/facade.ts | 6 +- .../ui-conversation/src/client/input/hub.ts | 28 ++++ .../src/client/skeleton/InputBar.tsx | 2 +- .../input-reference-submit.client.spec.ts | 17 +++ .../service-orchestration.client.spec.ts | 21 +++ .../ui-conversation/tsconfig.client.json | 3 + .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 + .../client/ui-model-selection/README.zh.md | 2 + .../client/ui-model-selection/package.json | 3 +- .../src/client/directory.ts | 14 ++ .../ui-model-selection/src/client/service.ts | 2 + .../tests/browser-plugin.client.spec.ts | 24 +++- .../client/ui-model-selection/tsconfig.json | 3 + .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 + .../client/ui-plugin-manager/README.zh.md | 2 + .../client/ui-plugin-manager/package.json | 3 +- .../ui-plugin-manager/src/client/index.ts | 2 +- .../src/client/manager-store.ts | 48 ++++++- .../tests/manager-store.client.spec.ts | 60 ++++++++- .../ui-plugin-manager/tsconfig.client.json | 3 + .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 + .../client/ui-settings-account/README.zh.md | 2 + .../client/ui-settings-account/package.json | 3 +- .../src/client/AccountMenu.tsx | 4 +- .../src/client/AccountOnboarding.tsx | 4 +- .../src/client/AccountSection.tsx | 3 + .../src/client/DesktopOnboarding.tsx | 43 +++++-- .../src/client/OnboardingConfirmation.tsx | 5 +- .../src/client/SignInDialog.tsx | 12 +- .../ui-settings-account/src/client/index.ts | 7 +- .../src/client/onboarding-contract.ts | 3 + .../tests/desktop-onboarding.client.spec.tsx | 23 +++- .../client/ui-settings-account/tsconfig.json | 3 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../client/ui-settings-models/package.json | 3 +- .../src/client/DeepSeekOnboardingDialog.tsx | 5 +- .../src/client/ProviderEditor.tsx | 4 +- .../ui-settings-models/src/client/index.ts | 2 + .../client/ui-settings-models/tsconfig.json | 11 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 + packages/client/ui-sidebar/README.zh.md | 2 + packages/client/ui-sidebar/package.json | 3 +- .../client/ui-sidebar/src/client/index.ts | 6 +- packages/client/ui-sidebar/tsconfig.json | 3 + packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + packages/client/ui-workspace/package.json | 3 +- .../client/ui-workspace/src/client/index.ts | 5 +- .../ui-workspace/src/client/navigation.ts | 7 +- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../tests/shortcuts.client.spec.ts | 2 +- packages/client/ui-workspace/tsconfig.json | 3 + .../src/client/api-catalog.ts | 8 +- .../src/client/slot-catalog.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 31 +++++ .../product-telemetry-otel/README.i18n.yaml | 4 +- .../host/product-telemetry-otel/README.md | 2 +- .../host/product-telemetry-otel/README.zh.md | 2 +- .../references/packages.md | 1 + pnpm-lock.yaml | 76 +++++++++++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 7 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 149 files changed, 1555 insertions(+), 126 deletions(-) create mode 100644 packages/client/product-analytics/README.i18n.yaml create mode 100644 packages/client/product-analytics/README.md create mode 100644 packages/client/product-analytics/README.zh.md create mode 100644 packages/client/product-analytics/package.json create mode 100644 packages/client/product-analytics/src/client/index.ts create mode 100644 packages/client/product-analytics/src/events.ts create mode 100644 packages/client/product-analytics/src/index.ts create mode 100644 packages/client/product-analytics/tests/collection.client.spec.ts create mode 100644 packages/client/product-analytics/tests/collection.host.spec.ts create mode 100644 packages/client/product-analytics/tsconfig.client.json create mode 100644 packages/client/product-analytics/tsconfig.host.json create mode 100644 packages/client/product-analytics/tsconfig.json create mode 100644 packages/client/product-analytics/tsdown.config.ts diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index ecbff835ce..50c7f9c8af 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -20,6 +20,7 @@ async function main(): Promise { installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) + process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = process.env.DSH_PRODUCT_ANALYTICS_ENABLED === '0' ? '0' : '1' const application = runProfile({ environment: loadLayeredEnv('dsh'), profile: 'desktop', diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 3778fa7c85..468cbf7678 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md /deepseek-harness-desktop: - en: 45561fc0dd3a1b20 - zh: f4b03355d4bd0693 + en: 84dbd7387e8791c9 + zh: ec1ce14b79e953f5 /deepseek-harness-desktop/key-technical-decisions: en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 5520b62967..f379b6f630 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,6 +2,8 @@ English | [中文](README.zh.md) +Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its launch-time switch. Web usage is excluded. + The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 66dc8e16e8..70e48c7597 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,6 +2,8 @@ [English](README.md) | 中文 +桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。 + 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0326324fae..9e3ed75bcb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -73,6 +73,7 @@ "vite": "^6.0.0", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", - "@deepseek-ai/dsh-atomic-write": "workspace:*" + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" } } diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index b7f4069bcb..606a7615d0 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { const [expiryNotice, setExpiryNotice] = useState(false) const [page, setPage] = useState('entry') const pageRef = useRef('entry') + const visiblePage = useRef('entry') const [attempt, setAttempt] = useState(null) const attemptRef = useRef(null) const [starting, setStarting] = useState(false) @@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) { }, [api, m.welcomeTitle]) useEffect(() => { + if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('view') + visiblePage.current = page if (page === 'key') input.current?.focus() else if (page === 'entry' && focusEntry.current) { focusEntry.current = false keyButton.current?.focus() } - }, [page]) + }, [page, api]) useEffect(() => { if (copyState !== 'copied' && copyState !== 'failed') return @@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { async function saveKey(event: FormEvent) { event.preventDefault() if (busyRef.current) return + void api.analytics?.('save-key') const value = draft.trim() if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) { @@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) { - - + +

{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}

- +
diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.tsx b/packages/client/ui-settings-account/src/client/SignInDialog.tsx index f07b505d8a..edc2c4d4a5 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignInDialog.tsx @@ -1,5 +1,6 @@ /** Account authorization dialog; errors allow retry after cancelling any active attempt. */ -import { useEffect, useState } from 'react' +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' +import { useEffect, useRef, useState } from 'react' import { Button, IconCloseOutlineRegular, IconLoadingOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountSnapshot } from './AccountSection.tsx' import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' @@ -8,7 +9,8 @@ import { authorizeUrlWithTheme } from './authorize-url.ts' import css from './SignInDialog.module.css' /** @param props - safe account state, localized copy, and user actions. @returns login dialog. */ -export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: { +export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t, track }: { + track?: TrackProductEvent | undefined account: AccountSnapshot /** Resolved scheme of the active Desktop theme; the copied link carries it. */ colorScheme: 'light' | 'dark' @@ -18,6 +20,8 @@ export function SignInDialog({ account, colorScheme, start, cancel, close, useAp useApiKey: () => void t: (key: AccountKey) => string }) { + const viewed = useRef(false) + useEffect(() => { if (!viewed.current) { viewed.current = true; track?.('auth_page_view', {}) } }, [track]) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) const [copyResult, setCopyResult] = useState<{ messageKey: 'copiedLink' | 'copyFailed' } | null>(null) @@ -76,9 +80,9 @@ export function SignInDialog({ account, colorScheme, start, cancel, close, useAp
+ onClick={active ? dismiss : () => { track?.('auth_page_click', { button_name: 'api-key' }); useApiKey() }}>{t(active ? 'cancel' : 'addApiKey')}
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 9e4eef835c..fab9001aaf 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -1,4 +1,5 @@ /** Desktop account settings registration and reconnecting Remote subscription. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { TranscriptViewMode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -24,7 +25,7 @@ import { en, zh, type AccountKey } from './locales.ts' import { AccountQuotaNotice, type AccountQuotaNoticeInjected } from './AccountQuotaNotice.tsx' import { DESKTOP_ONBOARDING_NAMESPACE, type OnboardingSettings } from '../onboarding-settings.ts' import { DesktopOnboardingController } from './onboarding-state.ts' -import { DesktopOnboardingEntry } from './DesktopOnboardingEntry.tsx' +import { DesktopOnboardingEntry, type DesktopOnboardingInjected } from './DesktopOnboardingEntry.tsx' import { readOnboardingApiKeyPresence } from './onboarding-credentials.ts' import { refreshAfterReturn } from './account-refresh.ts' export type { AccountSectionInjected, AccountSectionProps } from './AccountSection.tsx' @@ -155,6 +156,7 @@ export function apply(ctx: Context): void { if (reason === 'returned' && page === 'top-up') void refreshAfterReturn(refreshing, reRead) }) const operations: AccountSectionInjected = { + track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), subscribeSessionExpired: listener => ctx.remote.$on('deepseek-account/session-expired', listener), subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener), ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, @@ -229,13 +231,14 @@ export function apply(ctx: Context): void { }, 'account: desktop credential readiness') ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'desktop-onboarding', locale: 'settings.account', - inject: () => ({ + inject: (): DesktopOnboardingInjected => ({ hooks: { account: operations.hooks.account, onboarding: controller.state }, // Onboarding's recharge return re-reads profile and balance only. ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refresh) }, update: (change: OnboardingChange) => controller.update(change), complete: (reason: 'completed' | 'skipped') => controller.complete(reason), retry: () => controller.retry(), + track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), }), }, DesktopOnboardingEntry)) } diff --git a/packages/client/ui-settings-account/src/client/onboarding-contract.ts b/packages/client/ui-settings-account/src/client/onboarding-contract.ts index 5c1a82081c..4d3815d90e 100644 --- a/packages/client/ui-settings-account/src/client/onboarding-contract.ts +++ b/packages/client/ui-settings-account/src/client/onboarding-contract.ts @@ -1,4 +1,5 @@ /** Pure view inputs for the desktop onboarding flow. */ +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import type { OnboardingProgress } from '../onboarding-settings.ts' import type { AccountSnapshot } from './AccountSection.tsx' import type { PlatformPages } from './platform-pages.ts' @@ -21,6 +22,8 @@ export type OnboardingChange = Partial { cleanup(); vi.unstubAllGlobals() }) function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', balance: 'zero' | 'positive' | 'bonus' | 'failed' | 'loading' = 'positive', status: DesktopOnboardingState['status'] = 'ready', copy: typeof zh = zh, creditFunded = false) { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) + const track = vi.fn() const complete = vi.fn(async () => true) const retry = vi.fn(async () => true) // The shared host owns the native view; this flow only requests a page from it. @@ -25,7 +26,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal status, visible: true, error: status === 'error' ? 'settings' : null, creditFunded, progress: { version: 1, step, purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }, }) - return copy[key]} complete={complete} retry={retry} + return copy[key]} complete={complete} retry={retry} update={async (change) => { setState(current => ({ ...current, status: 'saving' })) const saved = await update(change) @@ -38,7 +39,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal } render() return { - complete, openPlatformPage, release, update, retry, + complete, openPlatformPage, release, update, retry, track, /** The viewer returning through the shared host's Back action. */ back: () => { returnFromPage?.() }, } @@ -330,3 +331,21 @@ it('returns from purpose to credit without dropping the selected purpose', async await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingBack })) }) expect(h.update).toHaveBeenCalledWith({ step: 'credit' }) }) + + +it('reports visible pages once, funded Continue, popup actions, and no close event', async () => { + const b = mount('credit', 'positive', 'ready', zh, true) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) }) + expect(b.track).toHaveBeenCalledWith('onboarding_popup_view', { popup_name: 'skip_charge' }) + b.track.mockClear() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.close })) }) + expect(b.track).not.toHaveBeenCalled() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'next' }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_use_case' }) +}) + +it('does not report the loading surface as a page', () => { + expect(mount('welcome', 'loading', 'loading').track).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-settings-account/tsconfig.json b/packages/client/ui-settings-account/tsconfig.json index a2c29cb37a..ac170f07db 100644 --- a/packages/client/ui-settings-account/tsconfig.json +++ b/packages/client/ui-settings-account/tsconfig.json @@ -55,6 +55,9 @@ }, { "path": "../../settings/settings" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 3db8dbb74f..11a87aa0d3 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -6,8 +6,8 @@ en: d1bc4d9ef67bfb19 zh: d095e4dc76f1c5e0 /deepseek-ai-dsh-client-ui-settings-models: - en: 65e1e8a17a23e576 - zh: 01de4e98a002d376 + en: 0db6fa836e1ff511 + zh: 0e2968763e1fb7a5 /deepseek-ai-dsh-client-ui-settings-models/summary: en: 53301a5d29936f69 zh: 9ac408a33571dbdd diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 3b21e7465a..2131749232 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -7,6 +7,8 @@ kind: "package-reference" English | [中文](README.zh.md) +Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded. + ## Summary `dsh-client-ui-settings-models` is the Models settings page of the dsh web client: users configure API keys (stored write-only under the profile's credential reference), edit each provider's model list, and hand-declare custom pi-ai routes, with provider rows and one editor card at a time. The page joins the provider directory, the settings document, and the credential descriptions into one shared snapshot, so a row's state stays consistent across all three. It also walks first-run users through two ordered dialogs — a versioned internal-testing notice and the conditional official-DeepSeek credential step. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 242263798e..b6762de368 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。 + ## 概述 `dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。 diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 587093b505..22bac7f522 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -57,7 +57,8 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@deepseek-ai/dsh-remote-mock": "workspace:*" + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index fdc32c4045..63c090b3d5 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -5,7 +5,7 @@ * official DeepSeek route. The step reuses that page's credential editor in * the onboarding plugin's shared modal, so the key is entered once. */ - +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -22,6 +22,8 @@ import styles from './DeepSeekOnboardingDialog.module.css' /** Registration-side dependencies of {@link DeepSeekOnboardingDialog}. */ export interface DeepSeekOnboardingInjected { + /** Desktop-only event collection. */ + track?: TrackProductEvent /** Whether first-run setup should show automatically. Explicit requests remain available. */ automatic: boolean hooks: { @@ -120,6 +122,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): readOnly={false} hideTitle credentialOnly + onSubmitCredential={() => { props.track?.('api_key_save_click', {}) }} credentialRequired autoFocusCredential cancelLabelKey="onboardingLater" diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 3ed7b42d87..cc21bade9c 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -87,6 +87,8 @@ export interface ProviderEditorProps { submitBusyLabelKey?: keyof typeof en /** Close the editor; `changed` reports whether an Apply committed. */ onClose: (changed: boolean) => void + /** Called on a credential form submission before the save starts. */ + onSubmitCredential?: () => void /** * Called once per change with whether the apply or the model list's * endpoint interrogation is in flight, so the owner can hold its surface @@ -529,7 +531,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { submitBusyLabelKey={props.submitBusyLabelKey ?? 'applying'} {...props.cancelLabelKey === undefined ? {} : { cancelLabelKey: props.cancelLabelKey }} onCancel={() => { props.onClose(false) }} - onSubmit={() => { void apply() }} + onSubmit={() => { props.onSubmitCredential?.(); void apply() }} /> ) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index a96665b11f..c80634a2b6 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -6,6 +6,7 @@ * Export discipline: * packages/client/AGENTS.md. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -98,6 +99,7 @@ export function apply(ctx: ClientContext): void { }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ automatic: credentialOnboarding, + track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), controller, hooks: { models: controller.store }, operations, diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index 13bf556904..121f44cdde 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -8,8 +8,12 @@ "src" ], "references": [ - { "path": "../../host/webserver" }, - { "path": "../../../vendor/schemastery" }, + { + "path": "../../host/webserver" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../../vendor/cordis" }, @@ -33,6 +37,9 @@ }, { "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index ee4d7543ff..83ac74dbf0 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -6,8 +6,8 @@ en: 7884381c2c554eca zh: e6d7199a9bb2f0f8 /deepseek-ai-dsh-client-ui-sidebar: - en: 3b3885c78b619c8a - zh: 2cabf9cfec748497 + en: e357f49fb1d1710e + zh: 2e61922b104f77eb /deepseek-ai-dsh-client-ui-sidebar/summary: en: af17f03561e5372b zh: 91a1b9f6c7a51025 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 2750ab706a..31839e55ad 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -7,6 +7,8 @@ kind: "package-reference" English | [中文](README.zh.md) +Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded. + ## Summary The dsh web client sidebar lets users recognize the active build, start a new session, collapse navigation to a 56px rail, browse Workspaces and Sessions, and open Settings. It preserves a bottom-pinned Settings entry and hides idle scrollbars without moving browser rows. New Session uses an explicitly selected Workspace, then the current Session's Workspace, then the most recently active Workspace; if none exists, it opens a blank New Session page. Deployments can replace the brand mark or name while retaining the navigation controls and rail geometry. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 32364811ae..11fcb415c3 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。 + ## 概述 dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将导航折叠为 56px 轨道、浏览 Workspace 与 Session,以及打开 Settings。它会将 Settings 入口固定在底部,并在隐藏空闲滚动条时避免浏览器行发生位移。New Session 优先使用显式选择的 Workspace,其次使用当前 Session 所属的 Workspace,再其次使用最近活跃的 Workspace;如果都不存在,则打开空白的 New Session 页面。部署可以替换品牌标记或名称,同时保留导航控件和轨道几何。 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index ab290ffde2..b0bace3247 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -62,7 +62,8 @@ "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "clsx": "^2.0.0", - "@deepseek-ai/dsh-client-shortcuts": "workspace:*" + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 603c143fa7..3cd24cfc12 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,4 +1,5 @@ /** Registers the sidebar shell and global panel navigation. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -66,7 +67,10 @@ export function apply(ctx: ClientContext): void { // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { workspaceNavigation.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, - selectPanel: (id) => { ctx.layout.selectPanel(id) }, + selectPanel: (id) => { + if (id === 'plugins' || id === 'schedules') ctx.get('productAnalytics')?.track('sidebar_menu_click', { menu_name: id === 'plugins' ? 'plugin' : 'cron' }) + ctx.layout.selectPanel(id) + }, hooks: { panels, shortcuts: ctx.shortcuts.catalog }, }) ctx.slots.inject('sidebar', () => ctx.slots.register({ diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index 903b24e14e..788b1b7a53 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../shortcuts/tsconfig.client.json" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index e9a2f19a98..75d4c1068c 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -6,8 +6,8 @@ en: 7c0f613105d4008a zh: c36c0cdff7e77576 /deepseek-ai-dsh-client-ui-workspace: - en: c611184cd49f130f - zh: caddcda48dc30f11 + en: b286adf3aca51710 + zh: 6122f6e7c8015197 /deepseek-ai-dsh-client-ui-workspace/summary: en: 1a76ace6a18e3ab5 zh: b274d1e98e133851 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index d19423f195..9b17e88d4b 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -7,6 +7,8 @@ kind: "package-reference" English | [中文](README.zh.md) +Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded. + ## Summary This package lets users browse grouped or flat Session lists, choose a Workspace for a new Session, and manage Workspaces and Sessions through add, rename, reorder, search, fork, archive, and Workspace deletion; the Session row menu and its hover buttons are slot lists that client plugins extend. Pending interactions appear as warning dots, and subagent-origin Sessions remain hidden. An idle, unarchived Session row with active scheduled tasks shows a clock mark, and its hover card lists those tasks. Canonically distinct folder paths remain separate Workspaces. Adding a Workspace requires a composed directory picker; without one, adding is unavailable. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f2bf73d483..6a375a1a61 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。 + ## 概述 本包让用户浏览分组或扁平的 Session 列表、为新 Session 选择 Workspace,并通过添加、重命名、重排序、搜索、fork、归档和删除 Workspace 来管理 Workspace 与 Session;Session 行菜单及其悬停按钮是可由客户端插件扩展的 slot 列表。待处理交互显示为警告点,subagent 来源的 Session 则保持隐藏。处于空闲状态且未归档、其 Session 有活动定时任务的 Session 行会显示时钟标记,其悬浮卡片会列出这些任务。规范化后仍有差异的文件夹路径会保留为独立 Workspace。添加 Workspace 需要组合目录选择器;没有目录选择器时,添加操作不可用。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index c9f220a07a..24b32b8120 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -78,7 +78,8 @@ "@types/react": "~18.3.1", "clsx": "^2.0.0", "react": "^18.2.0", - "@deepseek-ai/dsh-client-shortcuts": "workspace:*" + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 98ff858e35..2ecaf7e264 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -15,6 +15,7 @@ * its relative time and its trailing status line. Export discipline: * packages/client/AGENTS.md. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context } from '@deepseek-ai/cordis' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' @@ -208,7 +209,9 @@ export function apply(ctx: Context): void { }) const forkInjected = (): ForkSessionInjected => ({ forkSession: (sessionId) => { - uiWorkspace.forkSession(sessionId).catch(() => { + uiWorkspace.forkSession(sessionId, (childId) => { + ctx.get('productAnalytics')?.track('branch_session_click', { session_id: childId, parent_session_id: sessionId, click_position: 'sidebar' }) + }).catch(() => { // Fork or child-title failure leaves the list as it was. }) }, diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 1b9ff0be25..fbdc7d03e6 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -45,9 +45,10 @@ export interface UiWorkspace { /** * Fork a Session without changing the current selection. * @param sessionId - source Session. + * @param onCreated - observer before the optional child-title update. * @returns completion after child creation and inherited-title increment. */ - forkSession(sessionId: SessionId): Promise + forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise /** * Resolve the reusable or newly created blank Session for a Workspace. * @param workspaceId - target Workspace. @@ -215,8 +216,8 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) } - async forkSession(sessionId: SessionId): Promise { - await this.sessions.fork({ sessionId, increaseTitle: true }) + async forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise { + return this.sessions.fork({ sessionId, increaseTitle: true, ...onCreated === undefined ? {} : { onCreated } }) } startSession(workspaceId?: WorkspaceId): void { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index daea79d72c..7d64d78aba 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -485,7 +485,7 @@ describe('ui-workspace apply', () => { b.retain.mockClear() fork.forkSession('session' as never) await forkSession.mock.results[0]!.value - expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true }) + expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void }) expect(b.retain).not.toHaveBeenCalled() // The rename row raises the request the dialog entry reads; settling clears it. diff --git a/packages/client/ui-workspace/tests/shortcuts.client.spec.ts b/packages/client/ui-workspace/tests/shortcuts.client.spec.ts index 2a5fce24ed..0db600df11 100644 --- a/packages/client/ui-workspace/tests/shortcuts.client.spec.ts +++ b/packages/client/ui-workspace/tests/shortcuts.client.spec.ts @@ -48,7 +48,7 @@ async function bench(runtime: 'web' | 'desktop' = 'desktop') { locale.setLocale('en') ctx.provide('locale', locale) const navigation = { - startSession: vi.fn(), forkSession: vi.fn(async () => {}), archiveSession: vi.fn(async (_sessionId: SessionId) => {}), + startSession: vi.fn(), forkSession: vi.fn(async () => 'fork-child' as SessionId), archiveSession: vi.fn(async (_sessionId: SessionId) => {}), } const controls = createWorkspaceShortcutControls() const fiber = ctx.plugin((scoped) => { diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 496d215d1d..ab3b3c6025 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -73,6 +73,9 @@ }, { "path": "../shortcuts/tsconfig.client.json" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 41e5ec6321..325cd0fb36 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -213,9 +213,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'bounded results, or a business/transport error.', }, { - signature: 'fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise', + signature: 'fork(opts: { sessionId: SessionId atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void }): Promise', description: 'Fork a session from an exact inclusive prefix of the source; on resolution the child is catalogued and can be explicitly retained.', - parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title before resolving.' }], + parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.' }], returns: 'the child session id.', throws: ['when the fork fails, or when a requested child-title rename fails after creation.'], }, @@ -348,9 +348,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['on failure; a refused creation is also shown through the Workspace notice unless a later navigation or disposal superseded the request.'], }, { - signature: 'forkSession(sessionId: SessionId): Promise', + signature: 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', description: 'Fork a Session without changing the current selection.', - parameters: [{ name: 'sessionId', description: 'source Session.' }], + parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer before the optional child-title update.' }], returns: 'completion after child creation and inherited-title increment.', }, { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1f0ffab903..c288ffe5d4 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -241,10 +241,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number, messageId?: MessageId) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', + 'MessageId', 'MessageImageLoader', 'OpenFileOptions', 'RenderMessageImages', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c044c101e8..c40f8cdfb5 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1697,6 +1697,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'productAnalytics', + summary: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', + description: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', + methods: [ + { + signature: '@Remote enabled(): boolean', + description: 'Read the collection policy.', + parameters: [], + returns: 'whether this Host currently accepts Desktop analytics.', + }, + { + signature: '@Remote async report(event: ProductEvent): Promise', + description: 'Submit selected Desktop fields; missing identity is omitted and never generated.', + parameters: [{ name: 'event', description: 'typed product event without message contents or credentials.' }], + returns: 'after local submission; no delivery or warehouse acknowledgement.', + }, + ], + }, { key: 'productTelemetry', summary: 'Host analytics sender.', @@ -5789,6 +5808,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'OfficeToPdfResult', declaration: 'export interface OfficeToPdfResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: OfficeToPdfKey;\n readonly generation: OfficeToPdfGeneration;\n}', }, + { + name: 'OnboardingPage', + declaration: 'export type OnboardingPage = \'onboarding_welcome\' | \'onboarding_recharge\' | \'onboarding_use_case\' | \'onboarding_process\';', + }, { name: 'OneShotScheduleRecord', declaration: 'export type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord;', @@ -5929,6 +5952,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreToolDecision', declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n info?: ToolErrorInfo;\n} | {\n kind: \'cancel\';\n} | {\n kind: \'ask\';\n reason?: string;\n};', }, + { + name: 'ProductEvent', + declaration: 'export type ProductEvent = {\n [K in keyof ProductEventMap]: {\n eventName: K;\n attributes: ProductEventMap[K];\n timestamp: number;\n };\n}[keyof ProductEventMap];', + }, + { + name: 'ProductEventMap', + declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */', + }, { name: 'ProductTelemetryRecord', declaration: 'export interface ProductTelemetryRecord {\n eventName: string;\n body: string;\n timestamp: number;\n severityNumber?: SeverityNumber;\n attributes?: Record>;\n}', diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml index e35c7672f8..287dbe064f 100644 --- a/packages/host/product-telemetry-otel/README.i18n.yaml +++ b/packages/host/product-telemetry-otel/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 9aa34fb1dc95d8cf /deepseek-ai-dsh-host-product-telemetry-otel/use-this-package: - en: 08e0bcbc259d131b - zh: 1d7cbde2688a903f + en: 26d751042e98819b + zh: 3186108a702b348c /deepseek-ai-dsh-host-product-telemetry-otel/understand-the-implementation: en: 5d2c06a357a1c06a zh: 9a1a17535889f5e3 diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md index af1f2c415f..bee01e58cc 100644 --- a/packages/host/product-telemetry-otel/README.md +++ b/packages/host/product-telemetry-otel/README.md @@ -25,7 +25,7 @@ Send selected product usage events to an OTLP/HTTP collector. Events carry a nam ## Use this package -Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The shipped profiles do not mount it. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version. +Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version. ```yaml - name: '@deepseek-ai/dsh-host-product-telemetry-otel' diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md index ffdb682d9a..b39912619d 100644 --- a/packages/host/product-telemetry-otel/README.zh.md +++ b/packages/host/product-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。内置 profile 不挂载本插件。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 +在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 ```yaml - name: '@deepseek-ai/dsh-host-product-telemetry-otel' diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 020a479a2e..a16e349f66 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -62,6 +62,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-client-hmr` | yes | Web client graph synchronization and rebuilt-bundle reload transport | | `@deepseek-ai/dsh-client-locale` | no | Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries | | `@deepseek-ai/dsh-client-modules` | no | Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam | +| `@deepseek-ai/dsh-client-product-analytics` | yes | Desktop product event collection and authenticated Host reporting | | `@deepseek-ai/dsh-client-resources` | no | Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook | | `@deepseek-ai/dsh-client-shortcuts` | yes | Application keyboard command registry and physical-key routing | | `@deepseek-ai/dsh-client-ui-agent-preset` | no | Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bcc5c5e0a..6ba4846f63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -591,6 +591,9 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:* version: link:../../packages/util/atomic-write + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../../packages/client/product-analytics '@deepseek-ai/dsh-client-shortcuts': specifier: workspace:* version: link:../../packages/client/shortcuts @@ -1171,6 +1174,9 @@ importers: '@deepseek-ai/dsh-client-file-upload': specifier: workspace:* version: link:../../client/file-upload + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../../client/product-analytics '@deepseek-ai/dsh-client-ui-plugin-manager': specifier: workspace:* version: link:../../client/ui-plugin-manager @@ -2377,6 +2383,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:* version: link:../../client/modules + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../../client/product-analytics '@deepseek-ai/dsh-client-resources': specifier: workspace:* version: link:../../client/resources @@ -2578,6 +2587,9 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:* version: link:../../host/plugin-inventory + '@deepseek-ai/dsh-host-product-telemetry-otel': + specifier: workspace:* + version: link:../../host/product-telemetry-otel '@deepseek-ai/dsh-host-webserver': specifier: workspace:* version: link:../../host/webserver @@ -2902,6 +2914,46 @@ importers: specifier: workspace:* version: link:../../util/package-manifest + packages/client/product-analytics: + dependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:* + version: link:../../credentials/credentials + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:* + version: link:../../api/gateway + '@deepseek-ai/dsh-compaction': + specifier: workspace:* + version: link:../../compaction/compaction + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:* + version: link:../../credentials/deepseek-account + '@deepseek-ai/dsh-host-product-telemetry-otel': + specifier: workspace:* + version: link:../../host/product-telemetry-otel + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:* + version: link:../../host/webserver + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session + packages/client/resources: devDependencies: '@deepseek-ai/cordis': @@ -3182,6 +3234,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../locale + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../product-analytics '@deepseek-ai/dsh-client-store': specifier: workspace:* version: link:../store @@ -3372,6 +3427,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../locale + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../product-analytics '@deepseek-ai/dsh-client-shortcuts': specifier: workspace:* version: link:../shortcuts @@ -3952,6 +4010,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../locale + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../product-analytics '@deepseek-ai/dsh-client-store': specifier: workspace:* version: link:../store @@ -4229,6 +4290,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../locale + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../product-analytics '@deepseek-ai/dsh-client-store': specifier: workspace:* version: link:../store @@ -4615,6 +4679,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../locale + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../product-analytics '@deepseek-ai/dsh-client-store': specifier: workspace:* version: link:../store @@ -4797,6 +4864,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../locale + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../product-analytics '@deepseek-ai/dsh-client-store': specifier: workspace:* version: link:../store @@ -5088,6 +5158,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../locale + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../product-analytics '@deepseek-ai/dsh-client-shortcuts': specifier: workspace:* version: link:../shortcuts @@ -6023,6 +6096,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../locale + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../product-analytics '@deepseek-ai/dsh-client-shortcuts': specifier: workspace:* version: link:../shortcuts diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 666cebd31f..32813a3c33 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -53,6 +53,7 @@ export const SERVICE_PAGE: Record = { speechToText: 'voice-input.md', speechController: 'voice-input.md', productTelemetry: 'product-telemetry.md', + productAnalytics: 'product-telemetry.md', connection: 'web-server.md', pluginManager: 'boot.md', pluginRegistryProbe: 'boot.md', @@ -826,6 +827,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ /** Project types deliberately documented outside the subsystems catalog. */ export const TYPE_LINK_EXEMPTIONS: Readonly> = { + ProductEvent: 'Desktop event fields are owned by packages/client/product-analytics/README.md and src/events.ts', ConnectionFetchHandler: 'shared Fetch dispatch is owned by packages/client/connection/src/rpc.ts', ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts', ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0dc61657a9..2395937974 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -405,6 +405,13 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['llm-pi-ai'], note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.', }, + { + key: 'productAnalytics', + pkg: 'client-product-analytics', + title: 'Desktop interaction collection', + mode: 'service', + note: 'Accepts selected Desktop events, enriches available login identity, and observes live compaction under the launch-time collection policy.', + }, { key: 'productTelemetry', pkg: 'host-product-telemetry-otel', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 842708cc12..8d93abe461 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -45,6 +45,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { * blocks. A package moves on or off this list with its context behavior. */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/client/product-analytics': { kind: 'none', reason: 'Desktop analytics observes selected interactions without contributing model context or Session events.' }, 'packages/experimental/speech-to-text': { kind: 'none', reason: 'Routes transient recognition without adding model requests or Session events.' }, 'packages/experimental/api-speech-to-text': { kind: 'none', reason: 'Transports audio and preparation state; ordinary user submission owns model-visible text.' }, 'packages/experimental/speech-to-text-sensevoice': { kind: 'none', reason: 'Local recognition returns transient text without modifying model context.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index e2b7da61e8..074ddf99d9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,9 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { + "@deepseek-ai/dsh-client-product-analytics": ["./packages/client/product-analytics/src/index.ts"], + "@deepseek-ai/dsh-client-product-analytics/types": ["./packages/client/product-analytics/src/events.ts"], + "@deepseek-ai/dsh-client-product-analytics/client": ["./packages/client/product-analytics/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-settings-account": ["./packages/client/ui-settings-account/src/index.ts"], "@deepseek-ai/dsh-client-ui-settings-account/client": ["./packages/client/ui-settings-account/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/stream-protocol": ["./packages/api/gateway/src/stream-protocol.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 0471bfa13b..6d6d5bf726 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -43,6 +43,7 @@ "packages/client/*/tests/**/*.host.spec.ts" ], "references": [ + { "path": "./packages/client/product-analytics/tsconfig.client.json" }, { "path": "./tsconfig.desktop-keyboard-tests.json" }, { "path": "./apps/desktop/tsconfig.client.json" }, { "path": "./packages/experimental/client-ui-voice-input" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 718da78670..7ae07c6fe5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -194,6 +194,7 @@ "scripts/client-bundle-purity.spec.ts" ], "references": [ + { "path": "./packages/client/product-analytics/tsconfig.host.json" }, { "path": "./packages/experimental/speech-to-text" }, { "path": "./packages/experimental/speech-to-text-sensevoice" }, { "path": "./packages/experimental/api-speech-to-text" }, From 1dc523299972f2b4828a3c35bace96ab64862947 Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 19:29:30 +0800 Subject: [PATCH 02/10] fix(desktop): isolate analytics and redact installer inputs --- apps/desktop-host/src/index.ts | 4 +- .../tests/platform-session.spec.ts | 1 + apps/desktop/src/main.ts | 13 ++-- apps/desktop/src/update-coordinator.ts | 4 +- apps/desktop/src/welcome-api.ts | 2 - apps/desktop/src/welcome-window.ts | 6 +- apps/desktop/tests/update-coordinator.spec.ts | 10 +++ docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/credentials.md | 6 ++ docs/subsystems/credentials.zh.md | 6 ++ packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 3 + packages/boot/app-boot/README.zh.md | 3 + packages/boot/app-boot/src/index.ts | 1 + packages/boot/app-boot/src/launch-flag.ts | 15 +++++ .../boot/app-boot/tests/launch-flag.spec.ts | 11 ++++ packages/bundle/web-app/cordis.patch.yml | 9 ++- .../web-app/tests/product-analytics.spec.ts | 48 +++++++++++++++ .../client/product-analytics/README.i18n.yaml | 4 +- packages/client/product-analytics/README.md | 10 +-- .../client/product-analytics/README.zh.md | 10 +-- .../client/product-analytics/src/events.ts | 2 +- .../client/product-analytics/src/index.ts | 18 ++---- .../tests/collection.host.spec.ts | 39 +++++++----- .../tests/apply-inject.client.spec.tsx | 9 +++ .../src/client/input/editor/view-binding.ts | 2 +- .../src/client/input/facade.ts | 29 +++++++-- .../ui-conversation/src/client/input/hub.ts | 23 +++---- .../input-reference-submit.client.spec.ts | 61 ++++++++++++++++--- .../src/client/install-analytics.ts | 13 ++++ .../src/client/manager-store.ts | 7 ++- .../tests/install-analytics.client.spec.ts | 17 ++++++ .../tests/manager-store.client.spec.ts | 38 ++++++++++-- .../src/client/DesktopOnboarding.tsx | 4 +- .../src/client/OnboardingConfirmation.tsx | 4 +- .../tests/apply.client.spec.ts | 7 +++ .../tests/desktop-onboarding.client.spec.tsx | 14 ++++- .../tests/sign-in-dialog.client.spec.tsx | 9 +++ .../tests/apply.client.spec.ts | 6 ++ .../ui-workspace/src/client/navigation.ts | 2 +- .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 + .../deepseek-account-platform/README.zh.md | 2 + .../deepseek-account-platform/src/index.ts | 20 +++++- .../tests/account.spec.ts | 17 ++++++ .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/index.ts | 5 ++ .../src/client/api-catalog.ts | 2 +- .../tests/api-catalog.client.spec.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 8 ++- .../product-telemetry-otel/README.i18n.yaml | 4 +- .../host/product-telemetry-otel/README.md | 2 +- .../host/product-telemetry-otel/README.zh.md | 2 +- .../client-runtime/src/sessions.ts | 2 +- 59 files changed, 446 insertions(+), 120 deletions(-) create mode 100644 packages/boot/app-boot/src/launch-flag.ts create mode 100644 packages/boot/app-boot/tests/launch-flag.spec.ts create mode 100644 packages/bundle/web-app/tests/product-analytics.spec.ts create mode 100644 packages/client/ui-plugin-manager/src/client/install-analytics.ts create mode 100644 packages/client/ui-plugin-manager/tests/install-analytics.client.spec.ts diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 32ab8d9dee..34c4f88f45 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -2,7 +2,7 @@ import { delimiter, join } from 'node:path' import { inspect } from 'node:util' -import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv, loadProfileDirectory, resolveLaunchFlag } from '@deepseek-ai/dsh-app-boot' import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -21,7 +21,7 @@ async function main(): Promise { installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) - process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = process.env.DSH_PRODUCT_ANALYTICS_ENABLED === '0' ? '0' : '1' + process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = resolveLaunchFlag('DSH_PRODUCT_ANALYTICS_ENABLED', process.env.DSH_PRODUCT_ANALYTICS_ENABLED, true) ? '1' : '0' const application = runProfile({ environment: loadLayeredEnv('dsh'), profile: 'desktop', diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts index fbc26da6e1..4abc1b2055 100644 --- a/apps/desktop-host/tests/platform-session.spec.ts +++ b/apps/desktop-host/tests/platform-session.spec.ts @@ -9,6 +9,7 @@ const state: AccountView = { } class Account extends DeepSeekAccount { + getDeviceIdentity = async () => ({ osVersion: 'test-os' }) fail = false ended = Promise.withResolvers() reading = Promise.withResolvers() diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 95687d0e2e..91859afd3d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import { resolveLaunchFlag } from '@deepseek-ai/dsh-app-boot' import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ @@ -378,9 +379,7 @@ async function main(): Promise { let injections: readonly unknown[] = [] let welcomeBackend: DesktopWelcomeBackend | undefined let reportedLaunch = false - const analyticsSetting = process.env.DSH_PRODUCT_ANALYTICS_ENABLED - if (analyticsSetting !== undefined && analyticsSetting !== '0' && analyticsSetting !== '1') throw new Error('DSH_PRODUCT_ANALYTICS_ENABLED must be 0 or 1') - const analyticsEnabled = analyticsSetting !== '0' + const analyticsEnabled = resolveLaunchFlag('DSH_PRODUCT_ANALYTICS_ENABLED', process.env.DSH_PRODUCT_ANALYTICS_ENABLED, true) const track = async (eventName: K, attributes: ProductEventMap[K]): Promise => { if (!analyticsEnabled) return const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent @@ -578,7 +577,7 @@ async function main(): Promise { const result = await updateDialog.show(parent, confirmation) if (result.response !== 0 || isMandatory()) return false } - await track('desktop_upgrade_install_restart_click', {}) + void track('desktop_upgrade_install_restart_click', {}) if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable) try { const stillActive = await host.updateTasks('lock') @@ -604,7 +603,7 @@ async function main(): Promise { return true }, undefined, undefined, undefined, - (success) => { void track('desktop_upgrade_download_result', { is_success: success, ...success ? {} : { error_reason: 'download_failed' } }) }, + (success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) }, ) const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) @@ -1098,14 +1097,14 @@ async function main(): Promise { const showWelcome = (): Promise => { if (quitting) return Promise.resolve() if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) { + if (!welcomeWindow.isVisible()) void track('auth_page_view', {}) welcomeWindow.show() welcomeWindow.focus() return Promise.resolve() } openingWelcome ??= (async () => { welcomeWindow = await openWelcomeWindow(locale, { - analyticsEnabled, - analytics: action => action === 'view' ? track('auth_page_view', {}) : action === 'save-key' ? track('api_key_save_click', {}) : track('auth_page_click', { button_name: action }), + ...analyticsEnabled ? { analytics: (action: 'view' | 'save-key' | 'sign_in' | 'api-key') => action === 'view' ? track('auth_page_view', {}) : action === 'save-key' ? track('api_key_save_click', {}) : track('auth_page_click', { button_name: action }) } : {}, takeNotice: () => { const notice = pendingWelcomeNotice pendingWelcomeNotice = undefined diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 7aa82baf9b..8d6eb808e7 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -53,7 +53,7 @@ export class DesktopUpdateCoordinator { private readonly updater: AppUpdater = autoUpdater, private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly currentVersion: () => string = () => app.getVersion(), - private readonly downloadResult?: (success: boolean) => void, + private readonly downloadResult?: (success: boolean, reason?: string) => void, ) { if (updater === autoUpdater) { // electron-updater omits this internal transport property from its public declarations. @@ -114,7 +114,7 @@ export class DesktopUpdateCoordinator { return this.setState({ phase: 'ready', version }) } catch (error) { this.downloaded = false - this.downloadResult?.(false) + this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed') return this.setState(this.failure(error, 'download')) } }).finally(() => { this.downloadOperation = undefined }) diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts index 210e6f94ef..8f6b8d560a 100644 --- a/apps/desktop/src/welcome-api.ts +++ b/apps/desktop/src/welcome-api.ts @@ -25,8 +25,6 @@ export type WelcomeNotice = 'session-expired' export interface WelcomeOperations { /** @param action - fixed welcome gesture; never carries credential contents. */ analytics?(action: 'sign_in' | 'api-key' | 'save-key' | 'view'): Promise - /** Whether native welcome gestures may be collected. */ - analyticsEnabled?: boolean /** @returns the pending notification, clearing it before another renderer can receive it. */ takeNotice(): Promise /** @returns account state after starting a login attempt. */ diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts index 70a65ed234..ef37f3f882 100644 --- a/apps/desktop/src/welcome-window.ts +++ b/apps/desktop/src/welcome-window.ts @@ -58,7 +58,7 @@ let disposeActiveHandlers: (() => void) | undefined */ export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise { const options = welcomeWindowOptions(process.platform, locale) - if (operations.analyticsEnabled && options.webPreferences?.additionalArguments) { + if (operations.analytics !== undefined && options.webPreferences?.additionalArguments) { options.webPreferences.additionalArguments.push('--dsh-product-analytics') } const window = new BrowserWindow(options) @@ -84,7 +84,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco ipcMain.handle(WELCOME_IPC.analytics, async (event, action: unknown) => { assertSender(event) if (action !== 'sign_in' && action !== 'api-key' && action !== 'save-key' && action !== 'view') throw new Error('desktop welcome: invalid analytics action') - if (operations.analyticsEnabled) await operations.analytics?.(action) + await operations.analytics?.(action) }) ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() }) ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => { @@ -120,7 +120,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile. if (active && !window.isDestroyed()) { window.show() - if (operations.analyticsEnabled) void operations.analytics?.('view') + void operations.analytics?.('view') } return window } diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index b5583b0cdf..790ad63fcf 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -232,3 +232,13 @@ it('reports one download result when callers share an operation and none for cac await f.coordinator.download('1.1.0-rc.2') expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(true) }) + + +it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), new Error('private URL')])('reports safe download failure classification: %s', async (error) => { + const f = fixture() + await f.coordinator.check() + f.downloadUpdate.mockRejectedValueOnce(error) + await f.coordinator.download('1.1.0-rc.2') + expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed') + expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private') +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 570e517bf8..7cc08b9fc5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -500,8 +500,8 @@ export interface Config { ## `@deepseek-ai/dsh-client-product-analytics` -- `inject`: `credentials` · `deepseekAccount` · `webServer` -- `source`: [`packages/client/product-analytics/src/index.ts:15`](../packages/client/product-analytics/src/index.ts) +- `inject`: `deepseekAccount` · `webServer` +- `source`: [`packages/client/product-analytics/src/index.ts:13`](../packages/client/product-analytics/src/index.ts) ```ts config-catalog /** Application-owned collection policy; no user settings surface. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4411d10304..ac180f0acb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -502,8 +502,8 @@ export interface Config { ## `@deepseek-ai/dsh-client-product-analytics` -- `inject`: `credentials` · `deepseekAccount` · `webServer` -- `source`: [`packages/client/product-analytics/src/index.ts:15`](../packages/client/product-analytics/src/index.ts) +- `inject`: `deepseekAccount` · `webServer` +- `source`: [`packages/client/product-analytics/src/index.ts:13`](../packages/client/product-analytics/src/index.ts) ```ts config-catalog /** Application-owned collection policy; no user settings surface. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6626b801e8..c78dcbff5c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 11d630cdc7..8651e8c86a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -29,7 +29,7 @@ | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index decd5b088d..eb708f95f9 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read. */ abstract getPlatformSession(): Promise + +/** + * Read existing login identity without creating a device or returning credentials. + * @returns optional device/account identifiers and the provider's OS version string. + */ +abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }> ``` Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts) diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index b649f3c1ff..9a6ce9f3fa 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read. */ abstract getPlatformSession(): Promise + +/** + * Read existing login identity without creating a device or returning credentials. + * @returns optional device/account identifiers and the provider's OS version string. + */ +abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }> ``` Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 6c0d9fcddc..05537f4d34 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-app-boot/use-this-package: - en: f6fa378225009c8f - zh: 324c0e4640bb1358 + en: be4004cb1e04f7b4 + zh: eace2e738f6b6f97 /deepseek-ai-dsh-app-boot/use-this-package/when-to-use-it: en: 2fdfe02f2ed4a258 zh: d748228ece579c4a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index c32f8e4dd9..24f4c3a38b 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -11,6 +11,7 @@ English | [中文](README.zh.md) `dsh-app-boot` is the shared Loader boot library behind `dsh` profiles, including the CLI packaged by the Python runtime wheel. It loads environment layers, composes profile bundles and patches, boots every plugin, and returns the running app or identifies the failed plugin and cause. Product applications use the `dsh` launcher instead of publishing separate bins; direct-config helpers remain only for lower-level embedders and tests. You can preview the effective configuration before booting, configure HMR through profile YAML, and let a terminal-owning app restore its terminal before a fatal exit. + ## Table of Contents - [Use this package](#use-this-package) @@ -25,6 +26,8 @@ English | [中文](README.zh.md) ## Use this package +`resolveLaunchFlag(name, value, defaultValue)` accepts only `0`, `1`, or an absent value; other values fail with the variable name. + Starting an app with this package is a small, explicit entry point: you give it a config file and it runs the whole boot. This section covers what you can do and what you get; the helper calls behind each outcome are documented in the folded implementation section. ### When to use it diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 0bbdab2a5d..7edfc26de1 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -11,6 +11,7 @@ kind: "package-library" `dsh-app-boot` 是 `dsh` profile(包括 Python 运行时 wheel 包所含的 CLI(命令行界面))背后的共享 Loader 启动库。它加载环境层、组合 profile 组合包与 patch、启动每个插件,再返回运行中的应用,或指出失败插件与原因。产品应用使用 `dsh` launcher 而不发布单独 bin;直接配置 helper 只保留给低层嵌入方与测试。你还可以在启动前预览生效配置,按 profile 选择实时或仅启动时应用 patch,并让持有终端的应用在致命退出前恢复终端。 + ## 目录 - [使用本包](#use-this-package) @@ -25,6 +26,8 @@ kind: "package-library" ## 使用本包 +`resolveLaunchFlag(name, value, defaultValue)` 仅接受 `0`、`1` 或缺失值;其他值会抛出包含变量名的错误。 + 用此包启动应用是一个小而显式的入口:你给它一个配置文件,它运行整个启动过程。本节说明你能做什么、能得到什么;每个结果背后的 helper 调用记录在下方可折叠的实现章节中。 ### 何时使用 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 00515b4ad2..537a5a6b01 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -18,6 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' +export { resolveLaunchFlag } from './launch-flag.ts' export { sanitizeProfile } from './profile-sanitize.ts' export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts' export { diff --git a/packages/boot/app-boot/src/launch-flag.ts b/packages/boot/app-boot/src/launch-flag.ts new file mode 100644 index 0000000000..5c090f4f5b --- /dev/null +++ b/packages/boot/app-boot/src/launch-flag.ts @@ -0,0 +1,15 @@ +/** Strict boolean parsing for launcher-owned environment switches. */ + +/** + * Resolve an optional binary environment switch, rejecting misspelled values. + * @param name - environment variable used in configuration errors. + * @param value - inherited environment value. + * @param defaultValue - policy when the variable is absent. + * @returns the resolved switch. + */ +export function resolveLaunchFlag(name: string, value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined) return defaultValue + if (value === '0') return false + if (value === '1') return true + throw new Error(`${name} must be 0 or 1`) +} diff --git a/packages/boot/app-boot/tests/launch-flag.spec.ts b/packages/boot/app-boot/tests/launch-flag.spec.ts new file mode 100644 index 0000000000..b7ea1e1cb8 --- /dev/null +++ b/packages/boot/app-boot/tests/launch-flag.spec.ts @@ -0,0 +1,11 @@ +import { expect, it } from 'vitest' +import { resolveLaunchFlag } from '../src/launch-flag.ts' + +it.each([true, false])('uses the configured absent-value default: %s', (fallback) => { + expect(resolveLaunchFlag('FLAG', undefined, fallback)).toBe(fallback) + expect(resolveLaunchFlag('FLAG', '0', fallback)).toBe(false) + expect(resolveLaunchFlag('FLAG', '1', fallback)).toBe(true) +}) +it.each(['', 'false', 'no', '2'])('rejects invalid binary flags: %s', (value) => { + expect(() => resolveLaunchFlag('FLAG', value, true)).toThrow('FLAG must be 0 or 1') +}) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index b03be5ce40..1bbff405e4 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -44,16 +44,19 @@ - insert: - id: desktop-product-telemetry name: '@deepseek-ai/dsh-host-product-telemetry-otel' - disabled: !!js process.env.DSH_DESKTOP_PRODUCT_ANALYTICS !== '1' + disabled: !!js "ctx.get('profileContext')?.name !== 'desktop' || process.env.DSH_DESKTOP_PRODUCT_ANALYTICS !== '1'" config: + timeoutMillis: 1000 + exportTimeoutMillis: 1500 + shutdownTimeoutMillis: 2000 serviceName: deepseek-harness-desktop - serviceVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION ?? 'unknown' + serviceVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION endpoint: !!js process.env.DSH_PRODUCT_ANALYTICS_OTLP_URL - id: product-analytics name: '@deepseek-ai/dsh-client-product-analytics' config: - enabled: !!js process.env.DSH_DESKTOP_PRODUCT_ANALYTICS === '1' + enabled: !!js "ctx.get('profileContext')?.name === 'desktop' && process.env.DSH_DESKTOP_PRODUCT_ANALYTICS === '1'" appVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION # Host-owned opt-in sampled when a new Web session receives its preset diff --git a/packages/bundle/web-app/tests/product-analytics.spec.ts b/packages/bundle/web-app/tests/product-analytics.spec.ts new file mode 100644 index 0000000000..38d0340422 --- /dev/null +++ b/packages/bundle/web-app/tests/product-analytics.spec.ts @@ -0,0 +1,48 @@ +/** Desktop-only collector policy and bounded shutdown against an unresponsive receiver. */ +import { createServer } from 'node:http' +import { once } from 'node:events' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import * as Telemetry from '@deepseek-ai/dsh-host-product-telemetry-otel' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' + +afterEach(() => { vi.unstubAllEnvs() }) + +it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop launch: %s', async (profile) => { + const received = Promise.withResolvers() + const server = createServer((req) => { req.resume(); req.once('end', () => { received.resolve(undefined) }) }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + onTestFinished(async () => { const closed = once(server, 'close'); server.close(); server.closeAllConnections(); await closed }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('missing collector address') + vi.stubEnv('DSH_DESKTOP_PRODUCT_ANALYTICS', '1') + vi.stubEnv('DSH_PRODUCT_ANALYTICS_APP_VERSION', 'test-version') + vi.stubEnv('DSH_PRODUCT_ANALYTICS_OTLP_URL', `http://127.0.0.1:${address.port}/logs`) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('profileContext', { + name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json', + cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined, + }) + ctx.baseUrl = 'file:///' + await ctx.plugin(Loader).await() + ctx.loader.builtins.telemetry = Telemetry + const rows = loadOverlayPatches('analytics', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) + .flatMap(patch => patch.insert ?? []).filter(row => row.id === 'desktop-product-telemetry') + await ctx.loader.root.update(rows.map(row => ({ ...row, name: 'cordis:telemetry' }))) + await ctx.loader.await() + const entry = ctx.loader.resolve('desktop-product-telemetry') + expect(entry.disabled).toBe(profile !== 'desktop') + if (profile !== 'desktop') { + expect(ctx.get('productTelemetry')).toBeUndefined() + return + } + expect(rows[0]!.config).toMatchObject({ timeoutMillis: 1000, exportTimeoutMillis: 1500, shutdownTimeoutMillis: 2000 }) + ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() }) + const disposal = entry.fiber!.dispose() + await received.promise + await disposal +}) diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index d7c6ceb7ed..9fe052f618 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -15,8 +15,8 @@ en: 2fee9973f19f9401 zh: 8ab4b55413cb8408 /deepseek-ai-dsh-client-product-analytics/use-this-package: - en: 7636ab854070af43 - zh: 1a8ce4c24b7bbd51 + en: 704784e88b546490 + zh: a156401e08250c2b /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: en: a02729768db615ed zh: ed65aa11e5dab306 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 118fe1a3b4..f2f3b59d87 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -21,15 +21,17 @@ Collect selected Desktop interactions through the existing OTel product exporter ## Use this package -The Desktop launcher enables product analytics by default during testing. Set `DSH_PRODUCT_ANALYTICS_ENABLED=0` in its launch environment to disable it on the next start; `1` enables it. Other values are rejected. This setting controls native welcome and upgrade events, renderer events, and Host compaction collection together. Disabled launches create no exporter, inspect no analytics identity, and retain no events for later enablement. Session-feedback telemetry has its own policy. +The Desktop launcher enables product analytics by default. Set `DSH_PRODUCT_ANALYTICS_ENABLED=0` in its launch environment to disable it on the next start; `1` enables it. Other values are rejected. This setting controls native welcome and upgrade events, renderer events, and Host compaction collection together. Disabled launches create no exporter, inspect no analytics identity, and retain no events for later enablement. Session-feedback telemetry has its own policy. The shared composition supplies `enabled` (default `false`) and optional `appVersion` to this plugin. The Desktop Host enables it; an ordinary Web Host leaves it disabled. `DSH_PRODUCT_ANALYTICS_OTLP_URL` overrides the export destination for isolated collectors. The [exporter](../../host/product-telemetry-otel/README.md) owns batching, retry, and shutdown delivery. -Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Device identity reuses the existing login record without generating one. The Host selects only the account ID from the account session. Missing values are omitted; API keys, account tokens, prompts, and responses are never event fields. +Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Device identity reuses the existing login record without generating one. The Host reads credential-free device, account, and OS fields through `deepseekAccount.getDeviceIdentity()`. Missing values are omitted; API keys, account tokens, prompts, and responses are never event fields. -The [event types](src/events.ts) own names and allowed fields. Views count visible page entry, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Send captures `submit_source`, `submit_type`, model, effort, and `run_mode` at submission; plan takes precedence over an active goal. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. +The desktop Host derives internal `DSH_DESKTOP_PRODUCT_ANALYTICS=0|1` from the validated launch switch. Electron supplies internal `DSH_PRODUCT_ANALYTICS_APP_VERSION` from its running version; an enabled exporter requires it. These variables belong to the Desktop launcher-to-Host protocol and are not Web opt-in controls. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget. Enabling collection without the exporter logs a configuration warning. -Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation reports `result_status=success|failed|cancelled|unknown` independently from `error_reason` and keeps `is_success`. Duration is milliseconds from inspection through the terminal result, including internal registry retries. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result becomes `unknown`. +The [event types](src/events.ts) own names and allowed fields. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Message submission captures `submit_source`, `submit_type`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the default message sink reports, so handled commands are excluded. Programmatic submissions omit `submit_source`; keyboard and button gestures use `enter` and `click`. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. + +Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation reports `result_status=success|failed|cancelled|unknown` independently from `error_reason` and keeps `is_success`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result becomes `unknown`. ## Understand the implementation diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index d8f3b2a521..34398d5206 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -21,15 +21,17 @@ kind: "package-reference" ## 使用本包 -桌面启动器在测试阶段默认开启产品埋点。在启动环境中设置 `DSH_PRODUCT_ANALYTICS_ENABLED=0`,下次启动时关闭;`1` 表示开启。其他值会被拒绝。该设置统一控制原生登录与升级事件、渲染进程事件和 Host 压缩事件采集。关闭后不会创建导出器、读取埋点身份或保留事件供以后补发。会话反馈遥测采用独立策略。 +桌面启动器默认开启产品埋点。在启动环境中设置 `DSH_PRODUCT_ANALYTICS_ENABLED=0`,下次启动时关闭;`1` 表示开启。其他值会被拒绝。该设置统一控制原生登录与升级事件、渲染进程事件和 Host 压缩事件采集。关闭后不会创建导出器、读取埋点身份或保留事件供以后补发。会话反馈遥测采用独立策略。 共享组合向本插件提供 `enabled`(默认 `false`)和可选的 `appVersion`。桌面 Host 会开启它,普通 Web Host 保持关闭。`DSH_PRODUCT_ANALYTICS_OTLP_URL` 可覆盖导出目的地,用于隔离的接收端。[导出器](../../host/product-telemetry-otel/README.zh.md)负责批量发送、重试和退出时的交付。 -公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 只从账户会话中选择账户 ID。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。 +公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 通过 `deepseekAccount.getDeviceIdentity()` 读取不含凭据的设备、账户和操作系统字段。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。 -[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。发送时采集 `submit_source`、`submit_type`、模型、思考强度和 `run_mode`;计划模式优先于活跃目标。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 +桌面 Host 从经过校验的启动开关派生内部变量 `DSH_DESKTOP_PRODUCT_ANALYTICS=0|1`。Electron 通过内部变量 `DSH_PRODUCT_ANALYTICS_APP_VERSION` 传递运行版本;启用导出器时该值必填。这些变量属于桌面启动器与 Host 之间的协议,不是 Web 开启采集的控件。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。启用采集但未挂载导出器时会记录配置警告。 -插件条目使用 `plugin_type=plugin`,插件包使用 `bundle`。安装通过独立于 `error_reason` 的 `result_status=success|failed|cancelled|unknown` 表达结果,并保留 `is_success`。耗时单位为毫秒,从检查输入开始,到最终结果结束,包含内部源重试。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才成为 `unknown`。 +[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅默认消息发送路径上报,已处理的命令不计入。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 + +插件条目使用 `plugin_type=plugin`,插件包使用 `bundle`。安装通过独立于 `error_reason` 的 `result_status=success|failed|cancelled|unknown` 表达结果,并保留 `is_success`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才成为 `unknown`。 ## 了解实现 diff --git a/packages/client/product-analytics/src/events.ts b/packages/client/product-analytics/src/events.ts index ceb0975b86..18e7c72e77 100644 --- a/packages/client/product-analytics/src/events.ts +++ b/packages/client/product-analytics/src/events.ts @@ -24,7 +24,7 @@ export interface ProductEventMap { model_name?: string thinking_effort?: string run_mode: 'plan' | 'goal' | 'default' - submit_source: 'click' | 'enter' + submit_source?: 'click' | 'enter' submit_type: 'normal' | 'steer' | 'queue' } model_switch: { session_id?: SessionId; switch_from: string; switch_to: string } diff --git a/packages/client/product-analytics/src/index.ts b/packages/client/product-analytics/src/index.ts index df9baf0748..49202a943b 100644 --- a/packages/client/product-analytics/src/index.ts +++ b/packages/client/product-analytics/src/index.ts @@ -1,10 +1,8 @@ /** Desktop-only analytics RPC and live compaction collection. */ import { type Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { platform, release } from 'node:os' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-host-product-telemetry-otel' -import { credentialKey } from '@deepseek-ai/dsh-credentials' import type {} from '@deepseek-ai/dsh-deepseek-account' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-session' @@ -27,7 +25,7 @@ declare module '@deepseek-ai/cordis' { /** Authenticated event intake; disabled instances neither inspect identity nor construct an exporter. */ export default class ProductAnalytics extends TypertRemoteService { - static inject = ['credentials', 'deepseekAccount', 'webServer'] + static inject = ['deepseekAccount', 'webServer'] static Config = z.object({ enabled: z.boolean().default(false), appVersion: z.string() }) private readonly collection: { active: boolean } @@ -39,6 +37,7 @@ export default class ProductAnalytics extends TypertRemoteService { table.push({ kind: 'global', name: '__DSH_PRODUCT_ANALYTICS__', value: this.collection.active }) }) if (!this.collection.active) return + if (ctx.get('productTelemetry') === undefined) ctx.logger.warn('Product analytics is enabled without productTelemetry; configure the exporter') ctx.on('session/event', (session, event) => { if (event.type !== 'compaction/start') return void this.report({ eventName: 'context_compression', timestamp: Date.now(), @@ -62,21 +61,16 @@ export default class ProductAnalytics extends TypertRemoteService { async report(event: ProductEvent): Promise { if (!this.collection.active) return try { - const [device, account] = await Promise.all([ - this.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'device')).catch(() => undefined), - this.ctx.deepseekAccount.getPlatformSession().catch(() => undefined), - ]) + const identity = await this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined) if (!this.enabled()) return - const payload = device?.kind === 'grant' ? device.payload : undefined - const deviceId = typeof payload === 'object' && payload !== null && 'id' in payload && typeof payload.id === 'string' ? payload.id : undefined this.ctx.get('productTelemetry')?.emit({ ...event, body: event.eventName, attributes: { ...event.attributes, - ...deviceId === undefined ? {} : { device_id: deviceId }, - ...account?.userId == null ? {} : { user_id: account.userId }, + ...identity?.deviceId === undefined ? {} : { device_id: identity.deviceId }, + ...identity?.userId === undefined ? {} : { user_id: identity.userId }, ...this.config.appVersion === undefined ? {} : { app_version: this.config.appVersion }, - os_version: `${platform()} ${release()}`, + ...identity === undefined ? {} : { os_version: identity.osVersion }, }, }) } catch (error) { diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts index 1046aeefd9..106f9be988 100644 --- a/packages/client/product-analytics/tests/collection.host.spec.ts +++ b/packages/client/product-analytics/tests/collection.host.spec.ts @@ -14,30 +14,27 @@ afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await async function setup(enabled: boolean) { const ctx = new Context() cleanup.push(() => ctx.fiber.dispose()) - const readRecord = vi.fn().mockResolvedValue({ kind: 'grant', payload: { id: 'login-device', ignored: 'private-device-field' } }) - const getPlatformSession = vi.fn().mockResolvedValue({ userId: 'user-1', token: 'private-platform-token' }) + const getDeviceIdentity = vi.fn().mockResolvedValue({ deviceId: 'login-device', userId: 'user-1', osVersion: 'fixture-os', ignored: 'private-field' }) const emit = vi.fn<(record: ProductTelemetryRecord) => void>() - ctx.provide('credentials', { readRecord } as never) - ctx.provide('deepseekAccount', { getPlatformSession } as never) + ctx.provide('deepseekAccount', { getDeviceIdentity } as never) ctx.provide('webServer', {} as never) ctx.provide('productTelemetry', { emit } as never) const fiber = await ctx.plugin(Analytics, { enabled, appVersion: 'test-version' }) - return { ctx, fiber, readRecord, getPlatformSession, emit } + return { ctx, fiber, getDeviceIdentity, emit } } it('disabled collection does not read identity or submit an event', async () => { const b = await setup(false) expect(b.ctx.productAnalytics.enabled()).toBe(false) await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} }) - expect(b.readRecord).not.toHaveBeenCalled() - expect(b.getPlatformSession).not.toHaveBeenCalled() + expect(b.getDeviceIdentity).not.toHaveBeenCalled() expect(b.emit).not.toHaveBeenCalled() }) it('reuses login identity and copies only approved common fields', async () => { const b = await setup(true) await b.ctx.productAnalytics.report({ eventName: 'auth_page_click', timestamp: 100, attributes: { button_name: 'sign_in' } }) - expect(b.readRecord).toHaveBeenCalledWith('deepseek-account-platform/device') + expect(b.getDeviceIdentity).toHaveBeenCalledWith() expect(b.emit).toHaveBeenCalledExactlyOnceWith({ eventName: 'auth_page_click', body: 'auth_page_click', timestamp: 100, attributes: { button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version', os_version: expect.any(String) as string }, @@ -47,16 +44,15 @@ it('reuses login identity and copies only approved common fields', async () => { it('missing identity does not discard an otherwise valid event', async () => { const b = await setup(true) - b.readRecord.mockRejectedValueOnce(new Error('credential store unavailable')) - b.getPlatformSession.mockResolvedValueOnce(undefined) + b.getDeviceIdentity.mockRejectedValueOnce(new Error('credential store unavailable')) await b.ctx.productAnalytics.report({ eventName: 'plugin_add_button_click', timestamp: 100, attributes: {} }) - expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ app_version: 'test-version', os_version: expect.any(String) as string }) + expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ app_version: 'test-version' }) }) it('unload suppresses an identity lookup that settles after disposal', async () => { const b = await setup(true) const pending = Promise.withResolvers() - b.readRecord.mockReturnValueOnce(pending.promise) + b.getDeviceIdentity.mockReturnValueOnce(pending.promise) const reporting = b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} }) await b.fiber.dispose() pending.resolve(undefined) @@ -79,7 +75,7 @@ it('writes the selected event through the real exporter to an isolated collector const ctx = new Context() cleanup.push(() => ctx.fiber.dispose()) ctx.provide('credentials', { readRecord: async () => undefined } as never) - ctx.provide('deepseekAccount', { getPlatformSession: async () => undefined } as never) + ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never) ctx.provide('webServer', {} as never) const exporter = await ctx.plugin(ProductTelemetry, TelemetryConfig({ endpoint: `http://127.0.0.1:${address.port}/v1/logs`, serviceName: 'test', serviceVersion: '1', compression: 'none' })) await ctx.plugin(Analytics, { enabled: true }) @@ -101,7 +97,7 @@ it.each([true, false])('collects live manual and automatic compaction only when } if (enabled) await vi.waitFor(() => { expect(b.emit).toHaveBeenCalledTimes(2) }) expect(b.emit.mock.calls.map(([event]) => event.attributes?.trigger_type)).toEqual(enabled ? ['manual', 'auto'] : []) - expect(b.readRecord).toHaveBeenCalledTimes(enabled ? 2 : 0) + expect(b.getDeviceIdentity).toHaveBeenCalledTimes(enabled ? 2 : 0) }) it.each([true, false])('publishes the launch-time collection flag to renderers: %s', async (enabled) => { @@ -113,9 +109,22 @@ it.each([true, false])('publishes the launch-time collection flag to renderers: it('omits an unavailable account and isolates exporter submission failure', async () => { const b = await setup(true) - b.getPlatformSession.mockRejectedValueOnce(new Error('account unavailable')) + b.getDeviceIdentity.mockRejectedValueOnce(new Error('account unavailable')) await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} }) expect(b.emit.mock.calls[0]![0].attributes).not.toHaveProperty('user_id') b.emit.mockImplementationOnce(() => { throw new Error('exporter unavailable') }) await expect(b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 2, attributes: {} })).resolves.toBeUndefined() }) + + +it('warns when enabled without an exporter, while a disabled Host needs none', async () => { + for (const enabled of [false, true]) { + const ctx = new Context() + cleanup.push(() => ctx.fiber.dispose()) + ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never) + ctx.provide('webServer', {} as never) + const warn = vi.spyOn(ctx.logger, 'warn') + await ctx.plugin(Analytics, { enabled }) + expect(warn).toHaveBeenCalledTimes(enabled ? 1 : 0) + } +}) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 2b2590f908..619994a831 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -1,3 +1,4 @@ +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' // @vitest-environment jsdom /** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -170,6 +171,14 @@ describe('Chat inject API', () => { void injected.loadThrough(SessionSeq(42)) expect(b.session.loadThrough).toHaveBeenCalledWith(42) + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { track } as never) + const forkCreated = vi.spyOn(b.runtime.sessions, 'fork').mockImplementation(async (input) => { input.onCreated?.(ROOT); return ROOT }) + injected.forkAt(17) + expect(track).toHaveBeenCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, click_position: 'footer' }) + injected.forkAt(17, 'message-id' as MessageId) + expect(track).toHaveBeenCalledWith('branch_session_click', expect.objectContaining({ parent_message_id: 'message-id' })) + forkCreated.mockRestore() injected.forkAt(17) await vi.waitFor(() => { expect(b.openSession).toHaveBeenCalledWith(ROOT) diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index 89218a887c..f42b3a42be 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -134,7 +134,7 @@ export function installDraftKeymap( g.running, accelerated ? 'accelerated' : 'enter', g.steeringAvailable, - )) + ), 'enter') }, intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) }, pasteText: (text) => { diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 05811388b5..c13412be8d 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -42,7 +42,7 @@ export interface SessionInputDeps { /** Session-scope ctx handed to claim.submit transactions. */ actx: Context /** Capture mode and gesture before asynchronous submission or queue admission. */ - onSubmit?: (mode: InputSubmitMode, source: 'click' | 'enter') => void + captureSubmit?: (mode: InputSubmitMode, source: 'click' | 'enter' | undefined) => (() => void) | undefined /** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */ inputTriggers?: (() => InputTriggerController | undefined) | undefined /** PopupSelect shell face resolver (dismissal on submit lock / escape). */ @@ -132,6 +132,7 @@ export class SessionInputShell implements SessionInput { submit: () => { this.submit('queue') }, } + private readonly submittedAnalytics = new WeakMap void>() private readonly core = new SubmitMachine() private readonly draftEditor: DraftEditorRuntime private get projection(): EditorProjection { @@ -292,9 +293,12 @@ export class SessionInputShell implements SessionInput { * (adjudicating/submitting) force-closes the transient layers: the popup * dismisses and the menu tracks frozen. */ - submit(mode: InputSubmitMode = 'queue', source: 'click' | 'enter' = 'enter'): void { + submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void { if (this.disposed) return - if ((this.snapshot.phase === 'plain' || this.snapshot.phase === 'claimed') && (this.snapshot.draft.trim() !== '' || this.attachmentIds.length > 0)) this.deps.onSubmit?.(mode, source) + let report: (() => void) | undefined + if (this.snapshot.phase === 'plain' && (this.snapshot.draft.trim() !== '' || this.attachmentIds.length > 0)) { + try { report = this.deps.captureSubmit?.(mode, source) } catch (_error) { /* Analytics cannot interrupt submission. */ } + } if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) { if (this.snapshot.phase === 'plain') { const attachmentIds = [...this.attachmentIds] @@ -303,6 +307,7 @@ export class SessionInputShell implements SessionInput { const flight = this.attachmentFlightSeq this.attachmentFlights.set(flight, { controller, attachmentIds }) this.commitSend(attachmentIds) + this.reportSubmission(report) void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => { if (this.disposed || !this.attachmentFlights.delete(flight)) return if (outcome.kind === 'success') return @@ -325,7 +330,7 @@ export class SessionInputShell implements SessionInput { this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft)) return } - this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText })) + this.dispatchRun({ type: 'enter', mode, draft: this.projection.clipboardText }, report) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { this.deps.popup?.()?.dismiss() @@ -574,9 +579,15 @@ export class SessionInputShell implements SessionInput { } /** Dispatch + execute, refreshing the claim decoration when the styled token flips. */ - private dispatchRun(ev: Parameters[0]): void { + private dispatchRun(ev: Parameters[0], report?: () => void): void { const beforeToken = this.activeClaimToken() - this.run(this.core.dispatch(ev)) + const effects = this.core.dispatch(ev) + if (report !== undefined) { + for (const effect of effects) { + if ('attempt' in effect) this.submittedAnalytics.set(effect.attempt, report) + } + } + this.run(effects) if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration() } @@ -637,6 +648,8 @@ export class SessionInputShell implements SessionInput { draft: string, mode: InputSubmitMode, ): void { + this.reportSubmission(this.submittedAnalytics.get(attempt)) + this.submittedAnalytics.delete(attempt) const attachmentIds = [...this.attachmentIds] this.attachmentIds = [] const occurrences = this.projection.occurrences @@ -681,6 +694,10 @@ export class SessionInputShell implements SessionInput { ) } + private reportSubmission(report: (() => void) | undefined): void { + try { report?.() } catch (_error) { /* Analytics cannot interrupt submission. */ } + } + /** Settle one detached default send independently of other sends. */ private settleSink( attempt: SubmitAttempt, diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index cacd6e7aed..d2fcf8be65 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -8,7 +8,7 @@ * real host entity, so the sink is one unconditional prompt path. */ import type {} from '@deepseek-ai/dsh-client-product-analytics/client' -import type { ModelSelection, ModelSelectionProjection, ModelProviderGroup } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/types' import type { GoalProjection } from '@deepseek-ai/dsh-goal/types' import type { Context } from '@deepseek-ai/cordis' @@ -94,29 +94,22 @@ export class InputHub implements SessionInputResolver { const { session, ctx: actx } = binding const shell = new SessionInputShell({ actx, - onSubmit: (mode, source) => { + captureSubmit: (mode, source) => { const analytics = this.rootCtx.get('productAnalytics') if (!analytics?.enabled) return const state = session.getSnapshot() const model = session.projections.faceOf('modelSelection').getSnapshot() as ModelSelectionProjection | undefined const plan = session.projections.faceOf('plan').getSnapshot() as PlanProjection | undefined const goal = session.projections.faceOf('goal').getSnapshot() as GoalProjection | null | undefined - const directories = this.rootCtx.get('modelDirectories') as { - directoryFor(id: SessionId): { - store: ObservableSnapshot<{ current: ModelSelection | null; groups: readonly ModelProviderGroup[] }> - } - } | undefined - const directory = directories?.directoryFor(state.sessionId).store.getSnapshot() - const selection = directory?.current ?? model?.next ?? model?.lastUsed - const selectedGroup = directory?.groups.find(group => group.id === selection?.provider) - const selectedModel = selectedGroup?.models.find(candidate => candidate.id === selection?.model) - const effort = selection?.reasoningEffort ?? selectedModel?.reasoning?.defaultEffort - analytics.track('send_button_click', { + const selection = model?.next ?? model?.lastUsed + const effort = selection?.reasoningEffort + const attributes = { ...state.blank ? {} : { session_id: state.sessionId }, ...selection === undefined || selection === null ? {} : { model_name: `${selection.provider}/${selection.model}`, ...effort === undefined ? {} : { thinking_effort: effort } }, run_mode: plan?.active ? 'plan' : goal?.goal.phase === 'active' ? 'goal' : 'default', - submit_source: source, submit_type: state.running ? mode : 'normal', - }) + ...source === undefined ? {} : { submit_source: source }, submit_type: state.running ? mode : 'normal', + } as const + return () => { analytics.track('send_button_click', attributes) } }, inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index 6eb65a5d84..0d7e472a4f 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' -import type { InputTriggerController, SubmitOutcome } from '../src/client/contract/input.ts' +import type { InputTriggerController, SubmitOutcome, PickOutcome } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts' @@ -286,17 +286,64 @@ describe('submit transaction hardening', () => { it('captures click and Enter submission intent before async admission, excluding empty submits', async () => { - const onSubmit = vi.fn() - const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, onSubmit }) + const report = vi.fn() + const captureSubmit = vi.fn(() => report) + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, captureSubmit }) try { shell.submit('queue', 'click') - expect(onSubmit).not.toHaveBeenCalled() + expect(captureSubmit).not.toHaveBeenCalled() shell.setDraft('first') shell.submit('steer', 'click') - expect(onSubmit).toHaveBeenCalledExactlyOnceWith('steer', 'click') + expect(captureSubmit).toHaveBeenCalledExactlyOnceWith('steer', 'click') await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) shell.setDraft('second') - shell.submit('queue') - expect(onSubmit).toHaveBeenLastCalledWith('queue', 'enter') + shell.submit('queue', 'enter') + expect(captureSubmit).toHaveBeenLastCalledWith('queue', 'enter') + expect(report).toHaveBeenCalledTimes(2) + } finally { shell.dispose() } +}) + + +it.each(['capture', 'report'])('analytics %s failure does not interrupt a message', async (stage) => { + const sink = vi.fn(async (): Promise => ({ kind: 'success' })) + const fail = () => { throw new Error('analytics unavailable') } + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments, + captureSubmit: stage === 'capture' ? fail : () => fail }) + try { + shell.setDraft('message') + expect(() => { shell.submit('queue', 'click') }).not.toThrow() + expect(sink).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + } finally { shell.dispose() } +}) + + +it.each(['handled', 'claim', 'message'] as const)('counts only a message after asynchronous slash adjudication: %s', async (kind) => { + const pending = Promise.withResolvers() + const report = vi.fn() + const captureSubmit = vi.fn(() => report) + const command = vi.fn(async (): Promise => ({ kind: 'success' })) + const sink = vi.fn(async (): Promise => ({ kind: 'success' })) + const inputTriggers: InputTriggerController = { + launcher: { getSnapshot: () => null, subscribe: () => () => {} }, + lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} }, + track: () => {}, arbitrate: () => 'pass', onSpace: () => false, + serializeReference: async () => '', openReference: () => false, toggleSource: () => {}, + adjudicate: () => pending.promise, + } + const shell = new SessionInputShell({ actx: {} as Context, inputTriggers: () => inputTriggers, + captureSubmit, defaultSink: sink, commandAttachments }) + try { + shell.setDraft('/compact') + shell.submit('queue', 'enter') + expect(report).not.toHaveBeenCalled() + pending.resolve(kind === 'handled' ? 'handled' : kind === 'claim' ? { claim: { name: 'compact', token: '/compact', submit: command } } : undefined) + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + expect(report).toHaveBeenCalledTimes(kind === 'message' ? 1 : 0) + expect(sink).toHaveBeenCalledTimes(kind === 'message' ? 1 : 0) + expect(captureSubmit).toHaveBeenCalledExactlyOnceWith('queue', 'enter') + shell.setDraft('programmatic') + shell.actions.submit() + expect(captureSubmit).toHaveBeenLastCalledWith('queue', undefined) } finally { shell.dispose() } }) diff --git a/packages/client/ui-plugin-manager/src/client/install-analytics.ts b/packages/client/ui-plugin-manager/src/client/install-analytics.ts new file mode 100644 index 0000000000..f082408d3b --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/install-analytics.ts @@ -0,0 +1,13 @@ +/** Privacy-safe installation input classification shared by click and result events. */ + +/** + * Keep registry package names and plain versions; classify other installer inputs without their contents. + * @param spec - user-entered installation spec. + * @returns an identifier safe to send without URL credentials, tokens, or local paths. + */ +export function installationAnalyticsInput(spec: string): string { + if (/^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*(?:@[a-z0-9.*^~+<>=| -]+)?$/iu.test(spec)) return spec + if (/^(?:git[+:]|git@|github:|gitlab:|bitbucket:)/iu.test(spec)) return '[git]' + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(spec)) return '[url]' + return '[path-or-other]' +} diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 1691c3dc76..c7a7acf3ab 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -1,3 +1,4 @@ +import { installationAnalyticsInput } from './install-analytics.ts' /** * The plugin manager's state: the Host's bundles joined with its plugin * entries, the action in flight, the install run, and the confirmation an @@ -788,8 +789,8 @@ export class PluginManagerController { const spec = install.spec.trim() if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return if (this.ctx.get('productAnalytics')?.enabled) { - this.analyticsAttempt = { input: spec, started: Date.now() } - this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: spec, plugin_type: 'bundle' }) + this.analyticsAttempt = { input: installationAnalyticsInput(spec), started: Date.now() } + this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: installationAnalyticsInput(spec), plugin_type: 'bundle' }) } // A name the list already shows is refused at once, before the Host is asked. if (state.packages.some(pkg => pkg.name === spec)) { @@ -934,7 +935,7 @@ export class PluginManagerController { const install = this.getSnapshot().install const pending = install.failure?.pendingBuilds if (install.phase !== 'failed' || install.subject === null || pending === undefined || pending.length === 0) return - if (this.ctx.get('productAnalytics')?.enabled) this.analyticsAttempt = { input: install.subject.spec, started: Date.now() } + if (this.ctx.get('productAnalytics')?.enabled) this.analyticsAttempt = { input: installationAnalyticsInput(install.subject.spec), started: Date.now() } await this.startInstall(install.subject, pending) } diff --git a/packages/client/ui-plugin-manager/tests/install-analytics.client.spec.ts b/packages/client/ui-plugin-manager/tests/install-analytics.client.spec.ts new file mode 100644 index 0000000000..0a9eb89a7c --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/install-analytics.client.spec.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'vitest' +import { installationAnalyticsInput } from '../src/client/install-analytics.ts' + +it.each(['dsh-example', '@scope/plugin', '@scope/plugin@1.2.3', 'plugin@latest'])('retains a registry identifier: %s', (spec) => { + expect(installationAnalyticsInput(spec)).toBe(spec) +}) + +it.each([ + ['git+https://user:secret@example.invalid/org/repo.git?token=secret#secret', '[git]'], + ['https://user:secret@example.invalid/archive.tgz?signature=secret#secret', '[url]'], + ['git@example.invalid:private/repo', '[git]'], + ['/Users/private-name/plugin', '[path-or-other]'], + ['C:\\Users\\private-name\\plugin', '[path-or-other]'], + ['plugin@https://user:secret@example.invalid/archive.tgz', '[path-or-other]'], +])('removes private installer input: %s', (spec, expected) => { + expect(installationAnalyticsInput(spec)).toBe(expected) +}) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 46c1b0b6dc..2eec275f4c 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -71,7 +71,7 @@ const NO_CONFIG: HostObservable = { subscribe: () => () => {}, } -function bench(overrides: Partial>> = {}) { +function bench(overrides: Partial>> = {}, enabled = true) { const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) } const plugins = { listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))), @@ -89,7 +89,7 @@ function bench(overrides: Partial>> = {} const probe = { fastest: overrides.fastest ?? vi.fn(() => Promise.resolve(ok(null))) } const track = vi.fn() const ctx = { - get: () => ({ enabled: true, track }), + get: () => ({ enabled, track }), configForms: { describe: () => ({ getSnapshot: () => ({ view: { namespaces: [] } }), subscribe: () => () => {} }), get: vi.fn((id: string) => `form:${id}`) }, remote: { pluginManager: plugins, pluginInventory: inventory, pluginRegistryProbe: probe }, } as never @@ -859,7 +859,7 @@ describe('PluginManagerController', () => { expect(state().highlight).toBeNull() }) - it('offers the scripts a blocked run left pending, and retries the same spec with them allowed', async () => { + it.each([true, false])('offers the scripts a blocked run left pending, and retries with analytics enabled=%s', async (enabled) => { const gates: ReturnType> | ReturnType>>>[] = [] const { face, state, controller, plugins, started } = bench({ installBundle: vi.fn(() => { @@ -867,7 +867,7 @@ describe('PluginManagerController', () => { gates.push(gate) return gate.promise }), - }) + }, enabled) await controller.load() face.openInstall() face.editInstallSpec('x') @@ -1417,6 +1417,16 @@ it('recognizes the official registry without a trailing slash and with uppercase describe('desktop analytics outcomes', () => { + it('redacts authenticated installer URLs from click and failed-result events', async () => { + const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'refused' })) }) + b.face.openInstall() + b.face.editInstallSpec('git+https://user:private-token@example.invalid/repo.git?token=private-token') + b.face.runInstall() + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: '[git]' })) }) + expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]', plugin_type: 'bundle' }) + expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-token') + }) + it('reports input inspection failure separately from cancellation', async () => { const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'not a bundle' })) }) b.face.openInstall() @@ -1470,3 +1480,23 @@ describe('desktop analytics outcomes', () => { expect(b.track.mock.calls.filter(call => call[0] === 'plugin_toggle')).toHaveLength(2) }) }) + + +it('does not retain install identity when analytics is disabled', async () => { + const b = bench({}, false) + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') }) + expect(b.track.mock.calls.filter(([name]) => name === 'plugin_install_click' || name === 'install_plugin_result')).toEqual([]) +}) + +it('reports restart-required row toggles and tolerates a vanished inventory entry', async () => { + const b = bench({ setPluginEnabled: vi.fn(async () => ok({ ...APPLIED, application: 'restart-required' })) }) + await b.controller.load() + b.face.setRowEnabled(ROW_ENTRY, true) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', expect.objectContaining({ plugin_type: 'plugin' })) }) + b.face.setEnabled('vanished-package', true) + await vi.waitFor(() => { expect(b.state().busy).toEqual([]) }) + expect(b.track.mock.calls.filter(([name]) => name === 'plugin_toggle')).toHaveLength(1) +}) diff --git a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx index fbf7a7bd36..e8cbd0ac90 100644 --- a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx +++ b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx @@ -48,8 +48,8 @@ export function DesktopOnboarding({ const pageName = step === 'welcome' ? 'onboarding_welcome' : step === 'credit' ? 'onboarding_recharge' : step === 'purpose' ? 'onboarding_use_case' : 'onboarding_process' const shownPage = useRef(null) useEffect(() => { - const visible = state.visible && state.status !== 'loading' && step !== 'done' && !exiting - if (!visible) { shownPage.current = null; return } + if (!state.visible || step === 'done' || exiting) { shownPage.current = null; return } + if (state.status === 'loading') return if (shownPage.current === pageName) return shownPage.current = pageName track?.('onboarding_page_view', { page_name: pageName }) diff --git a/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx b/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx index e499bf60f9..b4ed52eb76 100644 --- a/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx +++ b/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx @@ -13,7 +13,7 @@ export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, on busy: boolean canRecharge: boolean onClose: () => void - onKeepSetting?: () => void + onKeepSetting: () => void onContinue: () => void onRecharge: () => void onSkip: () => void @@ -27,7 +27,7 @@ export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, on

{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}

- +
diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 20a430fec6..f465553e57 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -271,8 +271,15 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return vi.stubGlobal('dshPlatform', { open: vi.fn(), setBounds: vi.fn(), close: vi.fn() }) await c.reload(SELF) const actions = operations(c) + const track = vi.fn() + const analytics = c.ctx.get('productAnalytics')! + vi.spyOn(analytics, 'track').mockImplementation(track) + actions.track?.('auth_page_view', {}) + expect(track).toHaveBeenCalledWith('auth_page_view', {}) const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay') .find(entry => entry.options.id === 'desktop-onboarding')!) as DesktopOnboardingInjected + onboarding.track?.('onboarding_page_view', { page_name: 'onboarding_recharge' }) + expect(track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) const host = injectedOf(platformHostEntry(c)!) as AccountPlatformHostInjected c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) c.mock.remote.account.getBalance.mockResolvedValue(ok(null)) diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx index 37dfa527f0..1ee7324909 100644 --- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx @@ -20,12 +20,14 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal returnFromPage = onClose return release }) + let changeStatus: (value: DesktopOnboardingState['status']) => void = () => {} const update = vi.fn(async (_change: Parameters[0]) => true) function App() { const [state, setState] = useState({ status, visible: true, error: status === 'error' ? 'settings' : null, creditFunded, progress: { version: 1, step, purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }, }) + changeStatus = (value) => { setState(current => ({ ...current, status: value })) } return copy[key]} complete={complete} retry={retry} update={async (change) => { setState(current => ({ ...current, status: 'saving' })) @@ -39,7 +41,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal } render() return { - complete, openPlatformPage, release, update, retry, track, + complete, openPlatformPage, release, update, retry, track, changeStatus: (value: DesktopOnboardingState['status']) => { changeStatus(value) }, /** The viewer returning through the shared host's Back action. */ back: () => { returnFromPage?.() }, } @@ -349,3 +351,13 @@ it('reports visible pages once, funded Continue, popup actions, and no close eve it('does not report the loading surface as a page', () => { expect(mount('welcome', 'loading', 'loading').track).not.toHaveBeenCalled() }) + + +it('does not repeat page or popup exposure during save and refresh', () => { + const b = mount('credit') + fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) + act(() => { b.changeStatus('saving') }) + act(() => { b.changeStatus('loading') }) + act(() => { b.changeStatus('ready') }) + expect(b.track.mock.calls.filter(([name]) => name === 'onboarding_page_view')).toHaveLength(1) +}) diff --git a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx index 0cf569c39a..9eebc21673 100644 --- a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx @@ -178,3 +178,12 @@ it('keeps an outstanding start open until the request settles', async () => { await act(async () => { pending.resolve(undefined); await pending.promise }) expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false) }) + + +it('reports entry once across rerenders with a supplied sender', () => { + const track = vi.fn() + const props = dialogProps(null) + const view = render() + view.rerender( { track(name, attributes) }} colorScheme="light" />) + expect(track).toHaveBeenCalledExactlyOnceWith('auth_page_view', {}) +}) diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 62196478f7..2372690ba2 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -141,6 +141,12 @@ describe('ui-settings-models apply', () => { }) const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')! expect(deepSeek.component).toBe(DeepSeekOnboardingDialog) + const analytics = (deepSeek.inject!() as object) as import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected + analytics.track?.('api_key_save_click', {}) + const track = vi.fn() + before.ctx.provide('productAnalytics', { track } as never) + analytics.track?.('api_key_save_click', {}) + expect(track).toHaveBeenCalledWith('api_key_save_click', {}) expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 }) const deepSeekInjected = ( deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index fbdc7d03e6..2ce1c187cf 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -46,7 +46,7 @@ export interface UiWorkspace { * Fork a Session without changing the current selection. * @param sessionId - source Session. * @param onCreated - observer before the optional child-title update. - * @returns completion after child creation and inherited-title increment. + * @returns the child SessionId after creation and inherited-title increment. */ forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise /** diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index c1ff5b44ef..95c17ea670 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -9,8 +9,8 @@ en: 7be860d52e1202db zh: a2612757253d67a6 /deepseek-ai-dsh-deepseek-account-platform/summary: - en: 6af5ed65ad6982e1 - zh: ee34205c3732739a + en: ce3da383e4b065d8 + zh: 56d68ad8ee1c27a8 /deepseek-ai-dsh-deepseek-account-platform/table-of-contents: en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 3358684d58..abaa130b40 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -19,6 +19,8 @@ Profile and balance HTTP 401 responses or top-level response code `40003` (inval Sign in through the system browser and keep the account credential in the existing local credential store. Local cancellation prevents late callbacks and exchange responses from signing the user in. +Device identity reads validate the existing login device record and expose only its ID, the current account ID, and the shared login OS version string. + ## Table of Contents - [Use this package](#use-this-package) diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 5b0c9ada91..f976a1890b 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -19,6 +19,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 通过系统浏览器登录,并将账号凭证保存在现有本地凭证存储中。本地取消会阻止迟到的回调和兑换响应使用户登录。 +设备身份读取会校验已有登录设备记录,仅提供设备 ID、当前账户 ID 和与登录共用的操作系统版本字符串。 + ## 目录 - [使用此包](#use-this-package) diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 90668486c7..382cd41dcf 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -343,6 +343,19 @@ export class PlatformAccount extends DeepSeekAccount { await this.removing } + override async getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }> { + const [record, session] = await Promise.all([ + this.ctx.credentials.readRecord(DEVICE), + this.getPlatformSession(), + ]) + const parsed = record?.kind === 'grant' ? device.safeParse(record.payload) : undefined + return { + ...parsed?.success ? { deviceId: parsed.data.id } : {}, + ...session?.userId == null ? {} : { userId: session.userId }, + osVersion: deviceOsVersion(), + } + } + override async getPlatformSession(): Promise { const lifetime = this.detailsLifetime const stored = await this.readCurrentGrant(lifetime) @@ -569,7 +582,7 @@ export class PlatformAccount extends DeepSeekAccount { const identity = device.parse(deviceRecord.payload) const result = exchange.safeParse(await this.request('auth_exchange', { code: receivedCode, code_verifier: verifier, redirect_uri: redirectUri, - device_id: identity.id, device_model: `${platform()}-${arch()}`, os_version: `${platform()} ${release()}`, + device_id: identity.id, device_model: `${platform()}-${arch()}`, os_version: deviceOsVersion(), }, signal, attempt.clientHeaders), { reportInput: true }) if (!result.success) this.rejectPayload('auth_exchange', result.error) const completionUrl = new URL(browserUrl(result.data.authorized_url, this.origin, '/dsh/authorized', this.rewriteBrowserOrigin)) @@ -640,3 +653,8 @@ export class PlatformAccount extends DeepSeekAccount { } } export default PlatformAccount + +/** OS identification shared by login and credential-free identity reads. */ +function deviceOsVersion(): string { + return `${platform()} ${release()}` +} diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 49375797ab..938a6f30c0 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -1657,3 +1657,20 @@ it('clears a completed login from snapshots while credential deletion is still s deletion.mockRestore() } }) + + +it('reads credential-free device identity without minting a device', async () => { + const f = await fixture() + expect(await f.account.getDeviceIdentity()).toEqual({ osVersion: expect.any(String) as string }) + const key = credentialKey('deepseek-account-platform', 'device') + expect(await f.ctx.credentials.readRecord(key)).toBeUndefined() + await f.ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { id: 'invalid' } })) + expect(await f.account.getDeviceIdentity()).not.toHaveProperty('deviceId') + await f.ctx.credentials.modifyRecord(key, async () => ({ kind: 'api-key', key: 'private' })) + expect(await f.account.getDeviceIdentity()).not.toHaveProperty('deviceId') + const deviceId = '01990000-0000-4000-8000-000000000001' + await f.ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { id: deviceId } })) + await storeAccount(f) + await f.account.getProfile(clientMetadata()) + expect(await f.account.getDeviceIdentity()).toEqual({ deviceId, userId: 'test-user', osVersion: expect.any(String) as string }) +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index b7cbd724f6..4771c5591d 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -9,8 +9,8 @@ en: fcacd6de573d7636 zh: 6dbbc05a66dbb126 /deepseek-ai-dsh-deepseek-account/summary: - en: 68ef9cc302633361 - zh: e7222c009825046c + en: c41643b81a07a489 + zh: 2c16999de40a67b9 /deepseek-ai-dsh-deepseek-account/table-of-contents: en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index f5510f4a9f..0c134ca325 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -21,6 +21,8 @@ getUnnotifiedBonuses returns the granted bonuses Platform has not yet recorded a Account consumers read stored login state, start or cancel a browser login, and sign out without editing API keys. Host model consumers resolve account credentials only for the provider-configured inference origin. +`getDeviceIdentity()` returns existing device and account IDs plus the login OS version string, without credentials or device creation. + ## Table of Contents - [Use this package](#use-this-package) diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index bec732e65e..24844185dc 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -21,6 +21,8 @@ getUnnotifiedBonuses 返回平台尚未记录为已展示的赠金及其所属 账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。 +`getDeviceIdentity()` 返回已有设备和账户 ID 以及登录使用的操作系统版本字符串,不返回凭据,也不创建设备标识。 + ## 目录 - [使用此包](#use-this-package) diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 626654b625..8fd19ecc33 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -109,6 +109,11 @@ export abstract class DeepSeekAccount extends Service { * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read. */ abstract getPlatformSession(): Promise + /** + * Read existing login identity without creating a device or returning credentials. + * @returns optional device/account identifiers and the provider's OS version string. + */ + abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }> } export default DeepSeekAccount diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 325cd0fb36..42d03757cd 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -351,7 +351,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', description: 'Fork a Session without changing the current selection.', parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer before the optional child-title update.' }], - returns: 'completion after child creation and inherited-title increment.', + returns: 'the child SessionId after creation and inherited-title increment.', }, { signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index a4fb6cfef2..c9039d726f 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -24,7 +24,7 @@ describe('Client Cordis inspect catalog', () => { .toEqual([ 'openSession(target: SessionTarget): void', 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', - 'forkSession(sessionId: SessionId): Promise', + 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', 'connectWorkspace(workspaceId: WorkspaceId): Promise', 'startSession(workspaceId?: WorkspaceId): void', 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 004911c968..a7fdbc5b46 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -919,6 +919,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'a Host-only snapshot, or null while signed out or when the credential changed during the read.', }, + { + signature: 'abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>', + description: 'Read existing login identity without creating a device or returning credentials.', + parameters: [], + returns: 'optional device/account identifiers and the provider\'s OS version string.', + }, ], }, { @@ -5958,7 +5964,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProductEventMap', - declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */', + declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source?: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */', }, { name: 'ProductTelemetryRecord', diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml index 287dbe064f..0c330f9c0d 100644 --- a/packages/host/product-telemetry-otel/README.i18n.yaml +++ b/packages/host/product-telemetry-otel/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 9aa34fb1dc95d8cf /deepseek-ai-dsh-host-product-telemetry-otel/use-this-package: - en: 26d751042e98819b - zh: 3186108a702b348c + en: a62d2f83bd8b4186 + zh: f439a20ac0c4858e /deepseek-ai-dsh-host-product-telemetry-otel/understand-the-implementation: en: 5d2c06a357a1c06a zh: 9a1a17535889f5e3 diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md index bee01e58cc..b387f3b3f4 100644 --- a/packages/host/product-telemetry-otel/README.md +++ b/packages/host/product-telemetry-otel/README.md @@ -25,7 +25,7 @@ Send selected product usage events to an OTLP/HTTP collector. Events carry a nam ## Use this package -Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version. +Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. The standalone example below reads `DSH_APP_VERSION`; Desktop supplies `DSH_PRODUCT_ANALYTICS_APP_VERSION` through its native launcher. Both must name the running release; the schema rejects an absent version. ```yaml - name: '@deepseek-ai/dsh-host-product-telemetry-otel' diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md index b39912619d..dea0d39e24 100644 --- a/packages/host/product-telemetry-otel/README.zh.md +++ b/packages/host/product-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 +在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。下方独立示例读取 `DSH_APP_VERSION`;桌面端由原生启动器提供 `DSH_PRODUCT_ANALYTICS_APP_VERSION`。两者均须为运行中的发布版本;缺少版本时 schema 会拒绝配置。 ```yaml - name: '@deepseek-ai/dsh-host-product-telemetry-otel' diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 5ad261b5b0..ccfa899ffc 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -677,7 +677,7 @@ export class TestSessions implements ISessions { * @param opts - source session id, optional cut anchor, and client title policy. * @returns the source id (no child record is created). */ - fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise { + fork(opts: Parameters[0]): Promise { this.calls.push({ method: 'fork', args: [opts] }) return Promise.resolve(opts.sessionId) } From 847890a526b85e7e2b102a72ee38f3bf1c291b7b Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 19:56:50 +0800 Subject: [PATCH 03/10] test(desktop): provide launch flag parser in Host lifecycle fixture --- apps/cli/tests/desktop-host.e2e.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index e486354a33..f177adadec 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -28,7 +28,13 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } writeFileSync(join(root, 'package.json'), '{"type":"module"}') writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') - writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})') + copyFileSync(fileURLToPath(new URL('../../../packages/boot/app-boot/lib/types/launch-flag.js', import.meta.url)), join(modules, 'dsh-app-boot', 'launch-flag.js')) + writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), ` + export { resolveLaunchFlag } from './launch-flag.js'; + export const loadProfileDirectory = () => ({ skippedBundles: [] }); + export const reportSkippedBundles = () => {}; + export const loadLayeredEnv = () => ({}); + `) writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}') writeFileSync(join(modules, 'dsh', 'profile-boot.js'), ` import { writeFileSync } from 'node:fs'; From 9f7ef18c03d619a0f6975dba8005eedcf5a615ab Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 22:58:55 +0800 Subject: [PATCH 04/10] refactor(analytics): centralize live policy and message submission context --- apps/cli/tests/desktop-host.e2e.ts | 2 - apps/desktop-host/src/index.ts | 3 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/account-backend.ts | 18 +++-- apps/desktop/src/client/WelcomePage.tsx | 12 +-- apps/desktop/src/main.ts | 16 ++-- apps/desktop/src/preload-welcome.ts | 5 +- apps/desktop/src/welcome-api.ts | 10 ++- apps/desktop/src/welcome-backend.ts | 7 ++ apps/desktop/src/welcome-window.ts | 22 ++++-- apps/desktop/tests/account-backend.spec.ts | 15 +++- apps/desktop/tests/main-startup.spec.ts | 18 +++-- apps/desktop/tests/welcome-host.spec.ts | 2 + .../tests/welcome-renderer.client.spec.tsx | 7 +- apps/desktop/tests/welcome-startup.spec.ts | 1 + apps/desktop/tests/welcome-window.spec.ts | 26 ++++++- apps/desktop/tsconfig.client.json | 3 + docs/config-catalog.md | 9 ++- docs/config-catalog.zh.md | 9 ++- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/subsystems/product-telemetry.md | 9 ++- docs/subsystems/product-telemetry.zh.md | 9 ++- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 +- packages/api/remotes/README.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 4 - packages/boot/app-boot/README.zh.md | 4 - packages/boot/app-boot/src/index.ts | 1 - packages/boot/app-boot/src/launch-flag.ts | 15 ---- .../boot/app-boot/tests/launch-flag.spec.ts | 11 --- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 5 +- .../web-app/tests/product-analytics.spec.ts | 33 ++++++++- .../client/product-analytics/README.i18n.yaml | 12 +-- packages/client/product-analytics/README.md | 12 +-- .../client/product-analytics/README.zh.md | 12 +-- .../client/product-analytics/package.json | 5 +- .../product-analytics/src/client/index.ts | 29 ++++++-- .../client/product-analytics/src/index.ts | 57 ++++++++++----- .../tests/collection.client.spec.ts | 58 ++++++++++++--- .../tests/collection.host.spec.ts | 65 ++++++++++++----- .../product-analytics/tsconfig.host.json | 6 ++ packages/client/ui-chat/src/client/apply.ts | 4 +- .../src/client/chat/TurnTailNodeView.tsx | 2 +- .../ui-chat/src/client/contract/slots.ts | 4 +- .../tests/apply-inject.client.spec.tsx | 14 +++- .../ui-chat/tests/chat-view.client.spec.tsx | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../client/contract/composer-submission.ts | 16 ++++ .../src/client/contract/input.ts | 6 +- .../src/client/input/facade.ts | 36 ++++----- .../ui-conversation/src/client/input/hub.ts | 23 +++--- .../src/client/input/machine.ts | 24 +++--- .../src/client/input/submission-analytics.ts | 22 ++++++ .../input-reference-submit.client.spec.ts | 73 ++++++++++++++++--- .../service-orchestration.client.spec.ts | 2 +- .../src/client/manager-store.ts | 8 +- ...analytics.ts => sanitize-install-input.ts} | 2 +- ... => sanitize-install-input.client.spec.ts} | 6 +- .../src/client/DesktopOnboarding.tsx | 29 +++++--- .../src/client/OnboardingConfirmation.tsx | 6 +- .../tests/apply.client.spec.ts | 3 +- .../src/client/slot-catalog.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 ++- pnpm-lock.yaml | 9 ++- 73 files changed, 605 insertions(+), 281 deletions(-) delete mode 100644 packages/boot/app-boot/src/launch-flag.ts delete mode 100644 packages/boot/app-boot/tests/launch-flag.spec.ts create mode 100644 packages/client/ui-conversation/src/client/input/submission-analytics.ts rename packages/client/ui-plugin-manager/src/client/{install-analytics.ts => sanitize-install-input.ts} (90%) rename packages/client/ui-plugin-manager/tests/{install-analytics.client.spec.ts => sanitize-install-input.client.spec.ts} (77%) diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index f177adadec..615270aae0 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -28,9 +28,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } writeFileSync(join(root, 'package.json'), '{"type":"module"}') writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') - copyFileSync(fileURLToPath(new URL('../../../packages/boot/app-boot/lib/types/launch-flag.js', import.meta.url)), join(modules, 'dsh-app-boot', 'launch-flag.js')) writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), ` - export { resolveLaunchFlag } from './launch-flag.js'; export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({}); diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index bfd01f4b7c..4a47a92ce8 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -2,7 +2,7 @@ import { delimiter, join } from 'node:path' import { inspect } from 'node:util' -import { loadLayeredEnv, loadProfileDirectory, resolveLaunchFlag, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv, loadProfileDirectory, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot' import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -21,7 +21,6 @@ async function main(): Promise { installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) - process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = resolveLaunchFlag('DSH_PRODUCT_ANALYTICS_ENABLED', process.env.DSH_PRODUCT_ANALYTICS_ENABLED, true) ? '1' : '0' reportSkippedBundles('dsh', profile) const application = runProfile({ environment: loadLayeredEnv('dsh'), diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 9189c4bffe..5eef9404af 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md /deepseek-harness-desktop: - en: 84dbd7387e8791c9 - zh: ec1ce14b79e953f5 + en: 2a9d7d1de31172d4 + zh: 6680e1bf85abb6f1 /deepseek-harness-desktop/closing-the-window-and-quitting: en: fb852f3bd70d8602 zh: 3d2b0a8d580d7f0a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3d1d252824..56750b7bbf 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its launch-time switch. Web usage is excluded. +Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f73621d3e9..4288b99f2e 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。 +桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 889b4371bc..bb79bbb526 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -67,9 +67,10 @@ export interface DesktopAccountBackend { * @param listener - state recipient. * @param failed - stream failure recipient. * @param expired - live credential-expiry recipient. + * @param policy - optional Desktop collection-policy recipient; disconnected streams publish false. * @returns stream disposer. */ - watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void + watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void, policy?: (enabled: boolean) => void): () => void } /** @@ -86,19 +87,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo state: () => call('getState'), start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }), - watch(listener, failed, expired) { + watch(listener, failed, expired, policy) { let closed = false let socket: WebSocket | undefined let retry: ReturnType | undefined const connect = (): void => { const streamId = randomUUID() const expiryStreamId = randomUUID() + const policyStreamId = randomUUID() void cookies().then((cookie) => { if (closed) return const url = new URL(REMOTE_STREAM_MUX_PATH, origin) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) socket.on('open', () => { + if (policy !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: policyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) }) @@ -106,6 +109,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo try { const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId === policyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') policy?.(frame.value) + else policy?.(false) + return + } if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') if (frame.type === 'item') listener(accountView(frame.value)) @@ -113,11 +121,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo } catch { socket?.close() } }) socket.on('error', () => { socket?.close() }) - socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) - }).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) + socket.on('close', () => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } }) + }).catch(() => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() - return () => { closed = true; clearTimeout(retry); socket?.close() } + return () => { closed = true; policy?.(false); clearTimeout(retry); socket?.close() } }, } } diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index c40d10e999..b367061732 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -70,7 +70,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { }, [api, m.welcomeTitle]) useEffect(() => { - if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('view') + if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {}) visiblePage.current = page if (page === 'key') input.current?.focus() else if (page === 'entry' && focusEntry.current) { @@ -88,7 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { async function saveKey(event: FormEvent) { event.preventDefault() if (busyRef.current) return - void api.analytics?.('save-key') + void api.analytics?.('api_key_save_click', {}) const value = draft.trim() if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) { @@ -202,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) { - - + +

{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}

- +
diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index f465553e57..a442d71cf4 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -272,8 +272,7 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return await c.reload(SELF) const actions = operations(c) const track = vi.fn() - const analytics = c.ctx.get('productAnalytics')! - vi.spyOn(analytics, 'track').mockImplementation(track) + c.ctx.provide('productAnalytics', { track } as never) actions.track?.('auth_page_view', {}) expect(track).toHaveBeenCalledWith('auth_page_view', {}) const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay') diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index c288ffe5d4..1f0ffab903 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -241,11 +241,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number, messageId?: MessageId) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', - 'MessageId', 'MessageImageLoader', 'OpenFileOptions', 'RenderMessageImages', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a632d89bec..08df52a9cb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1705,8 +1705,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'productAnalytics', - summary: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', - description: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', + summary: 'Authenticated event intake; disabled instances do not inspect identity or accept new events.', + description: 'Authenticated event intake; disabled instances do not inspect identity or accept new events.', methods: [ { signature: '@Remote enabled(): boolean', @@ -1714,6 +1714,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'whether this Host currently accepts Desktop analytics.', }, + { + signature: '@Remote({ mode: \'stream\' }) async *watchPolicy(signal: AbortSignal): AsyncIterable', + description: 'Stream the effective policy initially and after live configuration edits.', + parameters: [{ name: 'signal', description: 'subscriber lifetime.' }], + returns: 'current policy values until cancellation or service disposal.', + }, { signature: '@Remote async report(event: ProductEvent): Promise', description: 'Submit selected Desktop fields; missing identity is omitted and never generated.', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5d804ccb0..498f0979b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2934,9 +2934,6 @@ importers: packages/client/product-analytics: dependencies: - '@deepseek-ai/dsh-credentials': - specifier: workspace:* - version: link:../../credentials/credentials '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol @@ -2947,6 +2944,12 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-settings': + specifier: workspace:* + version: link:../../settings/settings '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis From 86a61155fc4e1324808c0550f1cf12f621670a89 Mon Sep 17 00:00:00 2001 From: zhc Date: Fri, 25 Sep 2026 19:49:21 +0800 Subject: [PATCH 05/10] docs(analytics): emphasize default Desktop reporting --- packages/client/product-analytics/README.i18n.yaml | 4 ++-- packages/client/product-analytics/README.md | 2 +- packages/client/product-analytics/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index 3de436b184..49e8e6bc67 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -9,8 +9,8 @@ en: bfcd910889485da9 zh: 74e27b92b9fafc33 /deepseek-ai-dsh-client-product-analytics/summary: - en: 925c09866fced039 - zh: 72e72219b2d0414a + en: 4b48b9e5de3a0e97 + zh: cc06154eef4c4f6a /deepseek-ai-dsh-client-product-analytics/table-of-contents: en: 2fee9973f19f9401 zh: 8ab4b55413cb8408 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 4201be51d9..fb5febd172 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Collect selected Desktop interactions through the existing OTel product exporter. The application can disable collection without a user-facing setting. Ordinary Web clients never submit these events, and missing login identity is omitted. +Desktop reports selected interactions through the existing OTel product exporter by default, without a user-facing control. Ordinary Web clients never submit these events, and missing login identity is omitted. ## Table of Contents diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index 447c2020c4..79193b76fb 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -通过现有 OTel 产品导出器采集指定的桌面端交互。应用可以关闭采集,无需提供用户设置入口。普通 Web 客户端不会提交这些事件,缺失的登录身份字段会省略。 +桌面端默认通过现有 OTel 产品导出器上报指定交互,不提供用户操作入口。普通 Web 客户端不会提交这些事件,缺失的登录身份字段会省略。 ## 目录 From d0a7678471f2df830b9e02590703d87d4dcbc49c Mon Sep 17 00:00:00 2001 From: zhc Date: Fri, 25 Sep 2026 21:07:33 +0800 Subject: [PATCH 06/10] fix(analytics): retain provider context for renderer reports --- .../client/product-analytics/README.i18n.yaml | 4 +-- packages/client/product-analytics/README.md | 2 +- .../client/product-analytics/README.zh.md | 2 +- .../product-analytics/src/client/index.ts | 13 ++++---- .../tests/collection.client.spec.ts | 33 +++++++++++++++++-- 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index 49e8e6bc67..dad13cf815 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -18,8 +18,8 @@ en: c136c4af1b72bdcb zh: 8e287dfed42cea82 /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: - en: 5fcaa1cb77e47875 - zh: 434925be40f7d6ed + en: 416b7e925f0852c9 + zh: c0b83bacf18e91b1 /deepseek-ai-dsh-client-product-analytics/model-experience: en: 291751b16b8a37e7 zh: 3b38008f4364ec41 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index fb5febd172..33ad1d849f 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -39,7 +39,7 @@ Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation re
Implementation internals — click to expand -Presentational components receive callbacks. Shared Client services resolve the optional Desktop analytics sender, which requires both the native preload marker and the synchronized Host policy. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host enriches identity immediately before queueing. It observes live compaction events without replaying Session history. No runtime invariant companion is published because collection has no independent delivery acknowledgement to compare. +Presentational components receive callbacks. Shared Client services resolve the optional Desktop analytics sender, which requires both the native preload marker and the synchronized Host policy. The sender retains its own RPC context when called by other plugins. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host enriches identity immediately before queueing. It observes live compaction events without replaying Session history. No runtime invariant companion is published because collection has no independent delivery acknowledgement to compare.
diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index 79193b76fb..cdd60bb58b 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -39,7 +39,7 @@ Electron 通过内部变量 `DSH_PRODUCT_ANALYTICS_APP_VERSION` 传递运行版
实现细节——点击展开 -展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。本包不发布运行时不变量伴随模块,因为采集没有可用于比较的独立交付确认。 +展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。发送器在被其他插件调用时保留自身的 RPC 上下文。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。本包不发布运行时不变量伴随模块,因为采集没有可用于比较的独立交付确认。
diff --git a/packages/client/product-analytics/src/client/index.ts b/packages/client/product-analytics/src/client/index.ts index 6dd8f4d38c..dfa990bfe6 100644 --- a/packages/client/product-analytics/src/client/index.ts +++ b/packages/client/product-analytics/src/client/index.ts @@ -13,9 +13,16 @@ declare module '@deepseek-ai/cordis' { class DesktopAnalytics extends Service { private collecting = false + // RPC dispatch stays in the provider context when Cordis traces a consumer call. + private readonly submit: (event: ProductEvent) => Promise constructor(ctx: Context) { super(ctx, 'productAnalytics') + this.submit = async (event) => { + try { await ctx.remote.productAnalytics.report(event) } catch (_error) { + // Missing or disconnected Remote services must not interrupt the interaction. + } + } if (!('dshDesktop' in globalThis)) return const stream = ctx.remote.$stream({ name: 'product analytics policy', open: signal => ctx.remote.productAnalytics.watchPolicy(signal), @@ -44,12 +51,6 @@ class DesktopAnalytics extends Service { const event = { eventName: name, attributes, timestamp } as ProductEvent void this.submit(event) } - - private async submit(event: ProductEvent): Promise { - try { await this.ctx.remote.productAnalytics.report(event) } catch (_error) { - // Missing or disconnected Remote services must not interrupt the interaction. - } - } } /** Analytics consumes authenticated RPC and its reconnecting policy stream. */ diff --git a/packages/client/product-analytics/tests/collection.client.spec.ts b/packages/client/product-analytics/tests/collection.client.spec.ts index b5c39e3ef1..bdf5e7e5c5 100644 --- a/packages/client/product-analytics/tests/collection.client.spec.ts +++ b/packages/client/product-analytics/tests/collection.client.spec.ts @@ -1,4 +1,4 @@ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, expect, it, vi } from 'vitest' import type { RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import * as Analytics from '../src/client/index.ts' @@ -28,8 +28,16 @@ async function setup(enabled = true) { }, dispose: async () => { disposed = true; wake?.() }, } - ctx.provide('remote', { productAnalytics: { report, watchPolicy: vi.fn() }, $stream: (value: RemoteStreamOptions) => { options = value; return stream } } as never) - ctx.provide('remote.productAnalytics', {} as never) + const remote = { + productAnalytics: { report, watchPolicy: vi.fn() }, + $stream: (value: RemoteStreamOptions) => { options = value; return stream }, + } + class TracedRemote extends Service { + constructor(ctx: Context) { super(ctx, 'remote') } + $stream = remote.$stream + } + await ctx.plugin(TracedRemote) + await ctx.plugin({ apply(child: Context) { child.provide('remote.productAnalytics', remote.productAnalytics as never) } }) await ctx.plugin(Analytics) if ('dshDesktop' in globalThis) await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(enabled) }) return { ctx, report, options: () => options!, @@ -92,3 +100,22 @@ it('fails closed on stream loss and accepts a fresh policy after reconnection', await b.policy(true) await b.fail() }) + +it('reports from a consumer without granting it the analytics Remote namespace', async () => { + vi.stubGlobal('dshDesktop', {}) + const b = await setup() + let click!: () => void + await b.ctx.plugin({ + inject: ['remote'], + apply(ctx: Context) { + click = () => { + expect(ctx.get('productAnalytics')?.enabled).toBe(true) + ctx.get('productAnalytics')?.track('sidebar_menu_click', { menu_name: 'plugin' }) + } + }, + }) + click() + expect(b.report).toHaveBeenCalledExactlyOnceWith({ + eventName: 'sidebar_menu_click', timestamp: expect.any(Number) as number, attributes: { menu_name: 'plugin' }, + }) +}) From 97b3e97657a40adf35ed89f4af008be5db3b1031 Mon Sep 17 00:00:00 2001 From: zhc Date: Fri, 25 Sep 2026 21:53:40 +0800 Subject: [PATCH 07/10] fix(desktop): report install confirmation before locking host requests --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/main.ts | 3 ++- apps/desktop/tests/main-startup.spec.ts | 17 +++++++++++++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 90652c6c58..d0b7e75c12 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md /deepseek-harness-desktop: - en: 2a9d7d1de31172d4 - zh: 6680e1bf85abb6f1 + en: b8e4f357cf330721 + zh: cdaaf35a061f41b1 /deepseek-harness-desktop/closing-the-window-and-quitting: en: c323e02e3f0305fe zh: 3c730db632136443 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 102ef4cded..4b7907df77 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. +Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d4076bc380..6acf695077 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。 +桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d59334bff7..9f4e778d4f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -580,7 +580,8 @@ async function main(): Promise { const result = await updateDialog.show(parent, confirmation) if (result.response !== 0 || isMandatory()) return false } - void track('desktop_upgrade_install_restart_click', {}) + // The update lock rejects new HTTP requests, including analytics intake. + await track('desktop_upgrade_install_restart_click', {}) if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable) try { const stillActive = await host.updateTasks('lock') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 91091eaa19..f1e14c314d 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1650,6 +1650,23 @@ describe('desktop main startup', () => { await expect(preparing).resolves.toBe(true) }) + it.each(['accepted', 'failed'] as const)('settles analytics intake before locking API admission and continues after intake failure: %s', async (outcome) => { + const host = await readyForUpdate() + await vi.waitFor(() => { expect(harness.analytics).toHaveBeenCalledWith(expect.objectContaining({ eventName: 'desktop_app_launch' })) }) + const intake = Promise.withResolvers() + harness.analytics.mockImplementationOnce(() => intake.promise) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + await vi.waitFor(() => { expect(harness.analytics).toHaveBeenLastCalledWith(expect.objectContaining({ eventName: 'desktop_upgrade_install_restart_click' })) }) + expect(host.updateTasks.mock.calls).toEqual([['inspect']]) + expect(host.stop).not.toHaveBeenCalled() + if (outcome === 'accepted') intake.resolve(undefined) + else intake.reject(new Error('local analytics intake timed out')) + await host.stopping.promise + host.exited.resolve() + await expect(preparing).resolves.toBe(true) + }) + it('reports a Platform storage cleanup failure as preparation failure without stopping the Host', async () => { const host = await readyForUpdate() harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) From 5d1beb324edb1234b4ced199ad4bf9dfe44db3b7 Mon Sep 17 00:00:00 2001 From: zhc Date: Fri, 25 Sep 2026 23:27:08 +0800 Subject: [PATCH 08/10] refactor(analytics): limit auth events and reuse client version --- apps/desktop/src/account-backend.ts | 25 +++++++++++-------- apps/desktop/src/main.ts | 4 +-- apps/desktop/tests/account-backend.spec.ts | 14 ++++++----- apps/desktop/tests/main-startup.spec.ts | 13 +++++----- packages/bundle/web-app/cordis.patch.yml | 4 +-- .../web-app/tests/product-analytics.spec.ts | 5 +++- .../client/product-analytics/README.i18n.yaml | 4 +-- packages/client/product-analytics/README.md | 4 +-- .../client/product-analytics/README.zh.md | 4 +-- .../ui-settings-account/README.i18n.yaml | 4 +-- packages/client/ui-settings-account/README.md | 2 +- .../client/ui-settings-account/README.zh.md | 2 +- .../src/client/AccountMenu.tsx | 4 +-- .../src/client/AccountOnboarding.tsx | 4 +-- .../src/client/AccountSection.tsx | 3 --- .../src/client/SignInDialog.tsx | 12 +++------ .../ui-settings-account/src/client/index.ts | 1 - .../tests/apply.client.spec.ts | 3 +-- .../tests/sign-in-dialog.client.spec.tsx | 16 +++++++----- .../product-telemetry-otel/README.i18n.yaml | 4 +-- .../host/product-telemetry-otel/README.md | 2 +- .../host/product-telemetry-otel/README.zh.md | 2 +- 22 files changed, 70 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index bb79bbb526..770d3e4e9d 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -67,10 +67,13 @@ export interface DesktopAccountBackend { * @param listener - state recipient. * @param failed - stream failure recipient. * @param expired - live credential-expiry recipient. - * @param policy - optional Desktop collection-policy recipient; disconnected streams publish false. + * @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false. * @returns stream disposer. */ - watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void, policy?: (enabled: boolean) => void): () => void + watch( + listener: (state: AccountView) => void, failed: () => void, expired: () => void, + onAnalyticsEnabledChanged?: (enabled: boolean) => void, + ): () => void } /** @@ -87,21 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo state: () => call('getState'), start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }), - watch(listener, failed, expired, policy) { + watch(listener, failed, expired, onAnalyticsEnabledChanged) { let closed = false let socket: WebSocket | undefined let retry: ReturnType | undefined const connect = (): void => { const streamId = randomUUID() const expiryStreamId = randomUUID() - const policyStreamId = randomUUID() + const analyticsPolicyStreamId = randomUUID() void cookies().then((cookie) => { if (closed) return const url = new URL(REMOTE_STREAM_MUX_PATH, origin) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) socket.on('open', () => { - if (policy !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: policyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) + if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) }) @@ -109,9 +112,9 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo try { const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) - if (frame.streamId === policyStreamId) { - if (frame.type === 'item' && typeof frame.value === 'boolean') policy?.(frame.value) - else policy?.(false) + if (frame.streamId === analyticsPolicyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) + else onAnalyticsEnabledChanged?.(false) return } if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } @@ -121,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo } catch { socket?.close() } }) socket.on('error', () => { socket?.close() }) - socket.on('close', () => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } }) - }).catch(() => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } }) + socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) + }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() - return () => { closed = true; policy?.(false); clearTimeout(retry); socket?.close() } + return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() } }, } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9f4e778d4f..eb75980ad7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -45,7 +45,7 @@ import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './upd import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts' import { desktopErrorState } from './startup-error.ts' import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts' -import { desktopClientMetadata } from './client-metadata.ts' +import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts' import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts' import { DesktopPolicyTestAuth } from './policy-test-auth.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' @@ -417,7 +417,7 @@ async function main(): Promise { const backend = new DesktopBackendController((onFailure) => { const hostInspectPort = developmentHostInspectPort(development) const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, - hostInspectPort, { ...process.env, DSH_PRODUCT_ANALYTICS_APP_VERSION: app.getVersion() }, onFailure, + hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure, primaryRuntime, resources, (next) => { platformView.setSession(next) }) return { diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index 148c7e0a7d..b9cee7b61a 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -49,12 +49,14 @@ it('receives live expiry separately from account snapshots', async () => { if (typeof address === 'string' || address === null) throw new Error('missing server address') const connected = once(server, 'connection') const expired = vi.fn() - const policy = vi.fn() + const onAnalyticsEnabledChanged = vi.fn() const snapshot = { status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } const received = Promise.withResolvers() const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve('')) - const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, policy) + const stop = backend.watch( + (value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged, + ) onTestFinished(stop) await connected // Logical streams share the socket; observe their opening frames before sending data. @@ -71,13 +73,13 @@ it('receives live expiry separately from account snapshots', async () => { socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot })) await received.promise socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true })) - await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(true) }) + await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) }) socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: false })) - await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(false) }) + await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) }) socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true })) - await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(true) }) + await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) }) socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: 'invalid policy' })) - await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(false) }) + await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) }) expect(expired).not.toHaveBeenCalled() socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' })) await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index f1e14c314d..abce2c0bbc 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -173,7 +173,7 @@ const harness = await vi.hoisted(async () => { }) let accountListener: ((state: AccountView) => void) | undefined let analyticsEnabled = true - let policyListener: ((enabled: boolean) => void) | undefined + let analyticsEnabledListener: ((enabled: boolean) => void) | undefined const analytics = vi.fn(async (_event: unknown) => {}) const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false } const trays: FakeTray[] = [] @@ -194,13 +194,14 @@ const harness = await vi.hoisted(async () => { platformCloseAndWait, get analyticsEnabled() { return analyticsEnabled }, - set analyticsEnabled(value: boolean) { analyticsEnabled = value; policyListener?.(value) }, + set analyticsEnabled(value: boolean) { analyticsEnabled = value; analyticsEnabledListener?.(value) }, watchAccount: ( - listener: (state: AccountView) => void, _failed: () => void, _expired: () => void, policy?: (enabled: boolean) => void, + listener: (state: AccountView) => void, _failed: () => void, _expired: () => void, + onAnalyticsEnabledChanged?: (enabled: boolean) => void, ) => { - policyListener = policy + analyticsEnabledListener = onAnalyticsEnabledChanged accountListener = listener - return () => { accountListener = undefined; policyListener = undefined } + return () => { accountListener = undefined; analyticsEnabledListener = undefined } }, publishAccount(state: AccountView) { accountListener?.(state) }, ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(), @@ -2064,7 +2065,7 @@ describe('desktop main startup', () => { profile: 'desktop-test-profile', }) expect(harness.hosts[0]!.environment).not.toBe(process.env) - expect(harness.hosts[0]!.environment?.DSH_PRODUCT_ANALYTICS_APP_VERSION).toBe('1.0.0') + expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3') expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} }) expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1) expect(harness.windows).toHaveLength(1) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 757c0922ae..cf2d277132 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -50,7 +50,7 @@ exportTimeoutMillis: 1500 shutdownTimeoutMillis: 2000 serviceName: deepseek-harness-desktop - serviceVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION + serviceVersion: !!js process.env.DSH_CLIENT_VERSION endpoint: !!js process.env.DSH_PRODUCT_ANALYTICS_OTLP_URL - id: product-analytics @@ -58,7 +58,7 @@ disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'" config: enabled: true - appVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION + appVersion: !!js process.env.DSH_CLIENT_VERSION # Host-owned opt-in sampled when a new Web session receives its preset # delegation tools. The Plugins page edits this settings namespace. diff --git a/packages/bundle/web-app/tests/product-analytics.spec.ts b/packages/bundle/web-app/tests/product-analytics.spec.ts index fbafdaae4d..178d775d6f 100644 --- a/packages/bundle/web-app/tests/product-analytics.spec.ts +++ b/packages/bundle/web-app/tests/product-analytics.spec.ts @@ -20,7 +20,7 @@ it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop l onTestFinished(async () => { const closed = once(server, 'close'); server.close(); server.closeAllConnections(); await closed }) const address = server.address() if (address === null || typeof address === 'string') throw new Error('missing collector address') - vi.stubEnv('DSH_PRODUCT_ANALYTICS_APP_VERSION', 'test-version') + vi.stubEnv('DSH_CLIENT_VERSION', 'test-version') vi.stubEnv('DSH_PRODUCT_ANALYTICS_OTLP_URL', `http://127.0.0.1:${address.port}/logs`) const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -68,6 +68,9 @@ it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop l expect(identity).not.toHaveBeenCalled() } } + const emit = vi.spyOn(ctx.productTelemetry, 'emit') + await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} }) + expect(emit).toHaveBeenCalledWith(expect.objectContaining({ attributes: { app_version: 'test-version' } })) lifetime.abort() await policy.return?.() ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() }) diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index dad13cf815..200ba4f9e9 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -15,8 +15,8 @@ en: 2fee9973f19f9401 zh: 8ab4b55413cb8408 /deepseek-ai-dsh-client-product-analytics/use-this-package: - en: c136c4af1b72bdcb - zh: 8e287dfed42cea82 + en: c8c05edf30b3959d + zh: 39c2147cf01d43b5 /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: en: 416b7e925f0852c9 zh: c0b83bacf18e91b1 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 33ad1d849f..84c67e30e2 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -27,9 +27,9 @@ The renderer and Electron subscribe to the Host policy through the existing auth Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Device identity reuses the existing login record without generating one. The Host reads credential-free device, account, and OS fields through `deepseekAccount.getDeviceIdentity()`. Missing values are omitted; API keys, account tokens, prompts, and responses are never event fields. -Electron supplies internal `DSH_PRODUCT_ANALYTICS_APP_VERSION` from its running version; the exporter requires it. This variable belongs to the Desktop launcher-to-Host protocol. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget. +Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget. -The [event types](src/events.ts) own names and allowed fields. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Message submission retains its original occurrence timestamp and captures `submit_source`, `submit_type`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Programmatic submissions omit `submit_source`; keyboard and button gestures use `enter` and `click`. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. +The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Message submission retains its original occurrence timestamp and captures `submit_source`, `submit_type`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Programmatic submissions omit `submit_source`; keyboard and button gestures use `enter` and `click`. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation reports `result_status=success|failed|cancelled|unknown` independently from `error_reason` and keeps `is_success`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result becomes `unknown`. diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index cdd60bb58b..f997f49dc3 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -27,9 +27,9 @@ kind: "package-reference" 公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 通过 `deepseekAccount.getDeviceIdentity()` 读取不含凭据的设备、账户和操作系统字段。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。 -Electron 通过内部变量 `DSH_PRODUCT_ANALYTICS_APP_VERSION` 传递运行版本;导出器要求该值存在。该变量属于桌面启动器与 Host 之间的协议。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。 +Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。 -[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交保留最初发生时间,并在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 +认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交保留最初发生时间,并在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 插件条目使用 `plugin_type=plugin`,插件包使用 `bundle`。安装通过独立于 `error_reason` 的 `result_status=success|failed|cancelled|unknown` 表达结果,并保留 `is_success`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才成为 `unknown`。 diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 645a98ab43..cb0531a5d8 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -6,8 +6,8 @@ en: 1fcba8f273b661fb zh: 14e1236f84ae39d7 /deepseek-ai-dsh-client-ui-settings-account: - en: 311be03fcfd8ccb8 - zh: e65daffe9234c40a + en: 9ff7ac2d70f70b59 + zh: ded522c4cfd6e040 /deepseek-ai-dsh-client-ui-settings-account/summary: en: 9ff357621753be69 zh: 3f7ede82acc9fa31 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index b9f38dacfa..a417443c7b 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -7,7 +7,7 @@ kind: "package-reference" English | [中文](README.zh.md) -Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded. +Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded. Workspace login dialogs emit no authentication events; native welcome owns authentication analytics. Server-expired account credentials clear the displayed account details and emit a localized sign-in reminder toast once; account snapshot replay does not repeat it. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 6edaab61a1..2d357ee54c 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -7,7 +7,7 @@ kind: "package-reference" [English](README.md) | 中文 -桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。工作区内的登录弹窗不发送认证事件;认证埋点由原生欢迎页负责。 账号凭据被服务端判定失效后,清空显示的账号详情,并弹出一次本地化的重新登录提示 toast;账号快照重放不会重复提示。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 822cb7fc20..ea5e9d4a6b 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -22,7 +22,7 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<' export function AccountMenu({ subscribeSessionExpired, subscribeModelSignInRequired, wide, settingsShortcut, openSettings, openOnboarding, settingsOpen, useAccount, useTheme, signOut, hasRunningAccountTasks, refreshAccount, bonusNoticeShown, bonusNoticeDismissed, - contactUs, showLogin, start, cancel, t, track, + contactUs, showLogin, start, cancel, t, }: AccountMenuProps) { const anchor = useRef(null) // The launcher outlives the panel, so a false-to-true edge is one Settings entry: @@ -82,7 +82,7 @@ export function AccountMenu({ else if (id === 'signin') beginSignIn() else void requestSignOut() }} /> - {account.loginVisible && !account.onboarding && { showLogin(false) }} useApiKey={() => { showLogin(false); openOnboarding('deepseek-official') }} />} {signedIn && signOutImpact !== undefined && & PropsLocale<'settings.account'> & InjectFace) { const account = useAccount(value => value) const colorScheme = useTheme(snapshot => snapshot.active.colorScheme) useEffect(() => { setOnboarding(true); return () => { setOnboarding(false) } }, [setOnboarding]) useEffect(() => { if (account.view?.status === 'credential-stored') complete() }, [account.view?.status, complete]) if (!account.view || account.view.status === 'credential-stored') return null - return { showLogin(false); complete() }} useApiKey={() => { showLogin(false); useApiKey() }} /> } diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index 60a326d9d7..ba6530bef1 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -1,5 +1,4 @@ /** Account settings renders safe Host state and explicit login actions. */ -import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import { Big } from 'big.js' import { useEffect, useRef, useState, type MouseEvent } from 'react' import { Button, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' @@ -34,8 +33,6 @@ export interface AccountSnapshot { /** Host operations injected into the Cordis-free account component. */ export interface AccountSectionInjected { - /** Desktop-only event collection supplied by the application. */ - track?: TrackProductEvent /** Subscribe to live credential-expiry notifications. * @param listener - callback after the current credential is removed. * @returns listener cleanup. diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.tsx b/packages/client/ui-settings-account/src/client/SignInDialog.tsx index edc2c4d4a5..f07b505d8a 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignInDialog.tsx @@ -1,6 +1,5 @@ /** Account authorization dialog; errors allow retry after cancelling any active attempt. */ -import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { Button, IconCloseOutlineRegular, IconLoadingOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountSnapshot } from './AccountSection.tsx' import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' @@ -9,8 +8,7 @@ import { authorizeUrlWithTheme } from './authorize-url.ts' import css from './SignInDialog.module.css' /** @param props - safe account state, localized copy, and user actions. @returns login dialog. */ -export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t, track }: { - track?: TrackProductEvent | undefined +export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: { account: AccountSnapshot /** Resolved scheme of the active Desktop theme; the copied link carries it. */ colorScheme: 'light' | 'dark' @@ -20,8 +18,6 @@ export function SignInDialog({ account, colorScheme, start, cancel, close, useAp useApiKey: () => void t: (key: AccountKey) => string }) { - const viewed = useRef(false) - useEffect(() => { if (!viewed.current) { viewed.current = true; track?.('auth_page_view', {}) } }, [track]) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) const [copyResult, setCopyResult] = useState<{ messageKey: 'copiedLink' | 'copyFailed' } | null>(null) @@ -80,9 +76,9 @@ export function SignInDialog({ account, colorScheme, start, cancel, close, useAp
+ onClick={active ? dismiss : useApiKey}>{t(active ? 'cancel' : 'addApiKey')}
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index fab9001aaf..a2d1cb38bf 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -156,7 +156,6 @@ export function apply(ctx: Context): void { if (reason === 'returned' && page === 'top-up') void refreshAfterReturn(refreshing, reRead) }) const operations: AccountSectionInjected = { - track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), subscribeSessionExpired: listener => ctx.remote.$on('deepseek-account/session-expired', listener), subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener), ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index a442d71cf4..393d5232ec 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -273,8 +273,7 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return const actions = operations(c) const track = vi.fn() c.ctx.provide('productAnalytics', { track } as never) - actions.track?.('auth_page_view', {}) - expect(track).toHaveBeenCalledWith('auth_page_view', {}) + expect(actions).not.toHaveProperty('track') const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay') .find(entry => entry.options.id === 'desktop-onboarding')!) as DesktopOnboardingInjected onboarding.track?.('onboarding_page_view', { page_name: 'onboarding_recharge' }) diff --git a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx index 9eebc21673..c03b3613d8 100644 --- a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx @@ -179,11 +179,15 @@ it('keeps an outstanding start open until the request settles', async () => { expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false) }) - -it('reports entry once across rerenders with a supplied sender', () => { +// Workspace login remains outside native welcome analytics even if a caller forwards a sender. +it('does not report workspace login views or login choices', async () => { const track = vi.fn() - const props = dialogProps(null) - const view = render() - view.rerender( { track(name, attributes) }} colorScheme="light" />) - expect(track).toHaveBeenCalledExactlyOnceWith('auth_page_view', {}) + const props = { ...dialogProps(null), track } + const view = render() + view.rerender() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signIn })) }) + expect(props.start).toHaveBeenCalledOnce() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.addApiKey })) }) + expect(props.useApiKey).toHaveBeenCalledOnce() + expect(track).not.toHaveBeenCalled() }) diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml index f02cccd867..6b65776e76 100644 --- a/packages/host/product-telemetry-otel/README.i18n.yaml +++ b/packages/host/product-telemetry-otel/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 9aa34fb1dc95d8cf /deepseek-ai-dsh-host-product-telemetry-otel/use-this-package: - en: a62d2f83bd8b4186 - zh: f439a20ac0c4858e + en: d2356d7abffcc0ce + zh: 13250317296816ce /deepseek-ai-dsh-host-product-telemetry-otel/understand-the-implementation: en: d7807324fdeb2b42 zh: 1bfe1e943e513500 diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md index 3ea8563ee8..09511b5094 100644 --- a/packages/host/product-telemetry-otel/README.md +++ b/packages/host/product-telemetry-otel/README.md @@ -25,7 +25,7 @@ Send selected product usage events to an OTLP/HTTP collector. Events carry a nam ## Use this package -Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. The standalone example below reads `DSH_APP_VERSION`; Desktop supplies `DSH_PRODUCT_ANALYTICS_APP_VERSION` through its native launcher. Both must name the running release; the schema rejects an absent version. +Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. The standalone example below reads `DSH_APP_VERSION`; Desktop supplies `DSH_CLIENT_VERSION` through its native launcher. Both must name the running release; the schema rejects an absent version. ```yaml - name: '@deepseek-ai/dsh-otel' diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md index e2674730da..13fa44eee7 100644 --- a/packages/host/product-telemetry-otel/README.zh.md +++ b/packages/host/product-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。下方独立示例读取 `DSH_APP_VERSION`;桌面端由原生启动器提供 `DSH_PRODUCT_ANALYTICS_APP_VERSION`。两者均须为运行中的发布版本;缺少版本时 schema 会拒绝配置。 +在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。下方独立示例读取 `DSH_APP_VERSION`;桌面端由原生启动器提供 `DSH_CLIENT_VERSION`。两者均须为运行中的发布版本;缺少版本时 schema 会拒绝配置。 ```yaml - name: '@deepseek-ai/dsh-otel' From 6c3df458c57dbab67559465739de90144a20a776 Mon Sep 17 00:00:00 2001 From: zhc Date: Fri, 25 Sep 2026 23:44:43 +0800 Subject: [PATCH 09/10] test: fix desktop version fixture and canonicalize installer lock paths --- apps/desktop/tests/welcome-startup.spec.ts | 1 + scripts/install-lefthook.spec.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 62c78746ad..86a3da51e4 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -176,6 +176,7 @@ it.each([false, true])('starts welcome onboarding without carrying update focus state.accountState.mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } }) if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] }) vi.useFakeTimers() + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3') vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile') vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node') vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm') diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index c4e9af44da..4234f06776 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -159,8 +159,10 @@ function gitDirectory(fixture: Fixture, root: string): string { } function commonDirectory(fixture: Fixture): string { + // Git expands Windows short paths and filesystem aliases before the installer resolves its lock. + const root = git(fixture, fixture.main, ['rev-parse', '--show-toplevel']) const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir']) - return isAbsolute(output) ? output : resolve(fixture.main, output) + return isAbsolute(output) ? output : resolve(root, output) } function hooksPath(fixture: Fixture, root: string): string { @@ -359,6 +361,7 @@ syncBuiltinESMExports() const result = await runInstaller(fixture, fixture.main, {}, ['--require', preload]) + expect(existsSync(probe), `lock injection missing: exit ${result.status}\n${result.stderr}`).toBe(true) expect(readFileSync(probe, 'utf8')).toBe('injected') const recovers = process.platform === 'win32' && code === 'EPERM' && !expires expect(result.status, result.stderr).toBe(recovers ? 0 : 1) From 3fc7cc6607a60311c871f71794c68deb77e72769 Mon Sep 17 00:00:00 2001 From: zhc Date: Sat, 26 Sep 2026 00:18:07 +0800 Subject: [PATCH 10/10] fix(analytics): align desktop event fields with product specification --- .../client/product-analytics/README.i18n.yaml | 4 +-- packages/client/product-analytics/README.md | 4 +-- .../client/product-analytics/README.zh.md | 4 +-- .../client/product-analytics/src/events.ts | 13 +++----- .../src/client/input/submission-analytics.ts | 5 ++- .../service-orchestration.client.spec.ts | 17 +++++++++- .../src/client/directory.ts | 6 ++-- .../ui-model-selection/src/client/service.ts | 1 + .../tests/browser-plugin.client.spec.ts | 18 ++++++----- .../src/client/manager-store.ts | 13 ++++---- .../tests/manager-store.client.spec.ts | 31 ++++++++++++++----- .../src/client/DesktopOnboarding.tsx | 4 +-- .../tests/desktop-onboarding.client.spec.tsx | 14 +++++++-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 14 files changed, 89 insertions(+), 47 deletions(-) diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index 200ba4f9e9..0847f8f241 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -15,8 +15,8 @@ en: 2fee9973f19f9401 zh: 8ab4b55413cb8408 /deepseek-ai-dsh-client-product-analytics/use-this-package: - en: c8c05edf30b3959d - zh: 39c2147cf01d43b5 + en: 6bd7bf60e279ba1c + zh: afd55f06585c6462 /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: en: 416b7e925f0852c9 zh: c0b83bacf18e91b1 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 84c67e30e2..307534adb4 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -29,9 +29,9 @@ Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Devic Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget. -The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Message submission retains its original occurrence timestamp and captures `submit_source`, `submit_type`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Programmatic submissions omit `submit_source`; keyboard and button gestures use `enter` and `click`. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. +The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup reports `button_name=close`. Funded Continue uses `continue`. Message submission retains its original occurrence timestamp and captures `msg_type=default|queue|steer`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. Message submissions and model or effort switches omit `session_id` while the Session is blank. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. -Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation reports `result_status=success|failed|cancelled|unknown` independently from `error_reason` and keeps `is_success`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result becomes `unknown`. +Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packages use `bundle`. Installation reports `is_success`; cancellation uses `is_success=false` and `error_reason=user_cancelled`, and an absent recovery result uses `is_success=false` and `error_reason=unknown_result`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result reports `unknown_result`. ## Understand the implementation diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index f997f49dc3..1a87f9fd0c 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -29,9 +29,9 @@ kind: "package-reference" Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。 -认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交保留最初发生时间,并在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 +认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗上报 `button_name=close`。有余额时的继续按钮使用 `continue`。消息提交保留最初发生时间,并在异步命令裁决前采集 `msg_type=default|queue|steer`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。计划模式优先于活跃目标。采集和上报异常不会中断提交。消息提交以及模型或思考强度切换在空白会话中均省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 -插件条目使用 `plugin_type=plugin`,插件包使用 `bundle`。安装通过独立于 `error_reason` 的 `result_status=success|failed|cancelled|unknown` 表达结果,并保留 `is_success`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才成为 `unknown`。 +只有 `plugin_toggle` 携带 `plugin_type`:插件条目使用 `plugin`,插件包使用 `bundle`。安装通过 `is_success` 表达结果;取消使用 `is_success=false` 和 `error_reason=user_cancelled`,恢复查询确认无结果时使用 `is_success=false` 和 `error_reason=unknown_result`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才上报 `unknown_result`。 ## 了解实现 diff --git a/packages/client/product-analytics/src/events.ts b/packages/client/product-analytics/src/events.ts index 18e7c72e77..7fc64a6bcc 100644 --- a/packages/client/product-analytics/src/events.ts +++ b/packages/client/product-analytics/src/events.ts @@ -11,11 +11,11 @@ export interface ProductEventMap { onboarding_page_view: { page_name: OnboardingPage } onboarding_page_click: { page_name: OnboardingPage - button_name: 'next' | 'back' | 'skip' | 'charge' | 'later' + button_name: 'next' | 'back' | 'skip' | 'charge' | 'later' | 'continue' selected_content?: 'office' | 'code' | 'code_office' | 'focus_result' | 'key_detail' | 'full_process' } onboarding_popup_view: { popup_name: 'skip_charge' | 'skip_setting' } - onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' } + onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' | 'close' } desktop_upgrade_click: Record desktop_upgrade_download_result: { is_success: boolean; error_reason?: string } desktop_upgrade_install_restart_click: Record @@ -24,8 +24,7 @@ export interface ProductEventMap { model_name?: string thinking_effort?: string run_mode: 'plan' | 'goal' | 'default' - submit_source?: 'click' | 'enter' - submit_type: 'normal' | 'steer' | 'queue' + msg_type: 'default' | 'steer' | 'queue' } model_switch: { session_id?: SessionId; switch_from: string; switch_to: string } thinking_level_switch: { session_id?: SessionId; switch_from: string; switch_to: string; model_name: string } @@ -39,17 +38,15 @@ export interface ProductEventMap { sidebar_menu_click: { menu_name: 'plugin' | 'cron' } plugin_toggle: { plugin_name: string; plugin_type: 'plugin' | 'bundle'; is_enabled: boolean; is_builtin: boolean } plugin_add_button_click: Record - plugin_install_click: { input_value: string; plugin_type: 'bundle' } + plugin_install_click: { input_value: string } install_plugin_result: { input_value: string - plugin_type: 'bundle' is_success: boolean - result_status: 'success' | 'failed' | 'cancelled' | 'unknown' error_reason?: string duration: number plugin_name?: string } - confirm_uninstall_plugin: { plugin_name: string; plugin_type: 'bundle' } + confirm_uninstall_plugin: { plugin_name: string } } /** Public page names independent of internal onboarding state names. */ diff --git a/packages/client/ui-conversation/src/client/input/submission-analytics.ts b/packages/client/ui-conversation/src/client/input/submission-analytics.ts index 1fb86ad3ab..a0f1a8e1c9 100644 --- a/packages/client/ui-conversation/src/client/input/submission-analytics.ts +++ b/packages/client/ui-conversation/src/client/input/submission-analytics.ts @@ -9,14 +9,13 @@ import type { MessageSubmission } from '../contract/composer-submission.ts' * @param submission - original occurrence and Session snapshot. */ export function reportMessageSubmission(ctx: Context, submission: MessageSubmission): void { - const { state, timestamp, source, mode } = submission + const { state, timestamp, mode } = submission if (state === undefined) return const model = state.model ctx.get('productAnalytics')?.track('send_button_click', { ...state.sessionId === undefined ? {} : { session_id: state.sessionId }, ...model === undefined ? {} : { model_name: `${model.provider}/${model.name}`, ...model.effort === undefined ? {} : { thinking_effort: model.effort } }, run_mode: state.runMode, - ...source === undefined ? {} : { submit_source: source }, - submit_type: state.running ? mode : 'normal', + msg_type: state.running ? mode : 'default', }, timestamp) } diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 05f6e815c7..6665284ae4 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -890,10 +890,25 @@ it('captures run mode and queue intent at submission before later projection cha b.shell.setDraft('private prompt') b.shell.submit('queue', 'click') expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { - session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', submit_source: 'click', submit_type: 'queue', + session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', msg_type: 'queue', }, expect.any(Number)) await b.runtime.sessions.setProjection('s1', 'plan', { active: false, pending: false }) expect(track).toHaveBeenCalledTimes(1) expect(JSON.stringify(track.mock.calls)).not.toContain('private prompt') } finally { await b.runtime.dispose() } }) + +it.each([ + { running: false, mode: 'queue', expected: 'default' }, + { running: true, mode: 'steer', expected: 'steer' }, +] as const)('reports msg_type=$expected without gesture fields', async ({ running, mode, expected }) => { + const b = await bench() + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never) + try { + await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = running; state.blank = true }) + b.shell.setDraft('message') + b.shell.submit(mode, 'enter') + expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { run_mode: 'default', msg_type: expected }, expect.any(Number)) + } finally { await b.runtime.dispose() } +}) diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index ad885ffac6..41f67832a0 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -53,6 +53,7 @@ export class ModelDirectory { * @param available - whether this session may use Agent-bound model RPCs. * @param catalog - Host-generation catalog shared by every Session. * @param projected - durable model selection projected from Session history. + * @param isBlank - whether this Session has no first message yet. * @param track - desktop-only callback after a successful user selection. */ constructor( @@ -61,6 +62,7 @@ export class ModelDirectory { private readonly available: () => boolean, private readonly catalog: ModelCatalogDirectory, private readonly projected: ObservableSnapshot, + private readonly isBlank: () => boolean, private readonly track?: TrackProductEvent, ) { this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() }) @@ -115,9 +117,9 @@ export class ModelDirectory { if (previous !== null) { const from = `${previous.provider}/${previous.model}` const to = `${selection.provider}/${selection.model}` - if (from !== to) this.track?.('model_switch', { session_id: this.sessionId, switch_from: from, switch_to: to }) + if (from !== to) this.track?.('model_switch', { ...this.isBlank() ? {} : { session_id: this.sessionId }, switch_from: from, switch_to: to }) if (from === to && previousEffort !== nextEffort) this.track?.('thinking_level_switch', { - session_id: this.sessionId, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default', + ...this.isBlank() ? {} : { session_id: this.sessionId }, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default', }) } this.store.update((s) => { s.status = 'ready'; s.pending = null; s.error = null }) diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index a5c4d1ad62..18fa88cc86 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -78,6 +78,7 @@ export class ModelDirectoryResolver extends Service { () => sessions.subagentAddress(sessionId) === undefined, this.catalog, binding.session.projections.faceOf('modelSelection'), + () => binding.session.getSnapshot().blank, (name, attributes) => this.ctx.get('productAnalytics')?.track(name, attributes), ) live.directories.set(binding, directory) diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 0719e8c476..1896332c02 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -138,7 +138,11 @@ async function bench(locale: 'zh' | 'en' = 'zh') { const scopes = new Map() const bindings = new Map SnapshotStore } } + session: { + sessionId: SessionId + getSnapshot: () => { blank: boolean } + projections: { faceOf: () => SnapshotStore } + } ctx: Context }>() const addressed = new Set() @@ -154,7 +158,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() await ctx.plugin(function probe() {}).await() - const mint = (key: string) => { + const mint = (key: string, blank = false) => { const id = sid(key) const handle = createScope(ctx, id) scopes.set(id, handle.ctx) @@ -165,7 +169,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { projections.set(id, projection) const binding = { sessionId: id, - session: { sessionId: id, projections: { faceOf: () => projection } }, + session: { sessionId: id, getSnapshot: () => ({ blank }), projections: { faceOf: () => projection } }, ctx: handle.ctx, } bindings.set(id, binding) @@ -528,17 +532,17 @@ describe('ui-model-selection dual entry', () => { }) -it('reports successful model and effort changes and no refused switch', async () => { +it.each([false, true])('reports accepted switches with blank=%s and no refused switch', async (blank) => { const b = await bench('en') - const scope = b.mint('analytics') + const scope = b.mint('analytics', blank) try { const face = b.seat().inject!(sid('analytics')) await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }) - expect(b.track).toHaveBeenCalledWith('model_switch', expect.objectContaining({ switch_to: 'deepseek-official/deepseek-v4-pro' })) + expect(b.track).toHaveBeenCalledWith('model_switch', { ...blank ? {} : { session_id: 'analytics' }, switch_from: 'deepseek-official/deepseek-v4-flash', switch_to: 'deepseek-official/deepseek-v4-pro' }) b.track.mockClear() await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' }) expect(b.track).toHaveBeenCalledExactlyOnceWith('thinking_level_switch', { - session_id: 'analytics', model_name: 'deepseek-official/deepseek-v4-pro', switch_from: 'max', switch_to: 'high', + ...blank ? {} : { session_id: 'analytics' }, model_name: 'deepseek-official/deepseek-v4-pro', switch_from: 'max', switch_to: 'high', }) b.track.mockClear() b.rejectSelection() diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index b9e823b53b..1a3dde4a82 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -6,7 +6,7 @@ import { sanitizeInstallInput } from './sanitize-install-input.ts' * after each action and after every `plugin-manager/changed` event, so a * change made on another surface shows here without a manual refresh. */ -import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/client' +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { @@ -615,7 +615,7 @@ export class PluginManagerController { }, uninstall: (packageName) => { this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => { - this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName, plugin_type: 'bundle' }) + this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName }) this.applied(await this.ctx.remote.pluginManager.removeBundle(packageName), packageName) }) this.patch({ confirm: { action: 'uninstall', packageName } }) @@ -790,7 +790,7 @@ export class PluginManagerController { if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return if (this.ctx.get('productAnalytics')?.enabled) { this.analyticsAttempt = { input: sanitizeInstallInput(spec), started: Date.now() } - this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec), plugin_type: 'bundle' }) + this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec) }) } // A name the list already shows is refused at once, before the Host is asked. if (state.packages.some(pkg => pkg.name === spec)) { @@ -1094,14 +1094,15 @@ export class PluginManagerController { }) } - private finishAnalytics(status: ProductEventMap['install_plugin_result']['result_status'], reason?: string, name?: string): void { + private finishAnalytics(status: 'success' | 'failed' | 'cancelled' | 'unknown', reason?: string, name?: string): void { const attempt = this.analyticsAttempt this.analyticsAttempt = undefined if (attempt === undefined || this.disposed) return + const errorReason = status === 'cancelled' ? 'user_cancelled' : status === 'unknown' ? 'unknown_result' : reason this.ctx.get('productAnalytics')?.track('install_plugin_result', { - input_value: attempt.input, plugin_type: 'bundle', is_success: status === 'success', result_status: status, + input_value: attempt.input, is_success: status === 'success', duration: Math.max(0, Date.now() - attempt.started), - ...reason === undefined ? {} : { error_reason: reason }, ...name === undefined ? {} : { plugin_name: name }, + ...errorReason === undefined ? {} : { error_reason: errorReason }, ...name === undefined ? {} : { plugin_name: name }, }) } diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 2eec275f4c..1169a9e444 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -313,7 +313,7 @@ describe('PluginManagerController', () => { }) it('always asks before uninstalling, and cancelling runs nothing', async () => { - const { plugins, face, state, controller } = bench() + const { plugins, face, state, controller, track } = bench() await controller.load() face.uninstall(BUNDLE.name) expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name }) @@ -321,10 +321,12 @@ describe('PluginManagerController', () => { expect(state().confirm).toBeNull() face.confirm() expect(plugins.removeBundle).not.toHaveBeenCalled() + expect(track).not.toHaveBeenCalled() face.uninstall(BUNDLE.name) face.confirm() expect(state().confirm).toBeNull() await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) }) + expect(track).toHaveBeenCalledExactlyOnceWith('confirm_uninstall_plugin', { plugin_name: BUNDLE.name }) await vi.waitFor(() => { expect(state().busy).toEqual([]) }) // A refused removal names the action it was. plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never) @@ -1423,7 +1425,7 @@ describe('desktop analytics outcomes', () => { b.face.editInstallSpec('git+https://user:private-token@example.invalid/repo.git?token=private-token') b.face.runInstall() await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: '[git]' })) }) - expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]', plugin_type: 'bundle' }) + expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]' }) expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-token') }) @@ -1432,8 +1434,8 @@ describe('desktop analytics outcomes', () => { b.face.openInstall() b.face.editInstallSpec('dsh-new') b.face.runInstall() - await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', result_status: 'failed', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) }) - expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new', plugin_type: 'bundle' }) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) }) + expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new' }) }) it('reports unknown only after recovery confirms the result is absent', async () => { @@ -1445,12 +1447,12 @@ describe('desktop analytics outcomes', () => { await vi.waitFor(() => { expect(b.plugins.waitForInstall).toHaveBeenCalledTimes(1) }) expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toEqual([]) lost.resolve(ok(null)) - await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ result_status: 'unknown', is_success: false })) }) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'unknown_result', is_success: false })) }) b.face.reconcileInstall() expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toHaveLength(1) }) - it('closing during inspection reports cancellation once without an error reason', async () => { + it('closing during inspection reports user_cancelled once', async () => { const inspecting = deferred>>() const b = bench({ inspect: vi.fn(() => inspecting.promise) }) b.face.openInstall() @@ -1462,8 +1464,9 @@ describe('desktop analytics outcomes', () => { await Promise.resolve() const results = b.track.mock.calls.filter(call => call[0] === 'install_plugin_result') expect(results).toHaveLength(1) - expect(results[0]?.[1]).toMatchObject({ result_status: 'cancelled', is_success: false }) - expect(results[0]?.[1]).not.toHaveProperty('error_reason') + expect(results[0]?.[1]).toMatchObject({ error_reason: 'user_cancelled', is_success: false }) + expect(results[0]?.[1]).not.toHaveProperty('result_status') + expect(results[0]?.[1]).not.toHaveProperty('plugin_type') expect(b.plugins.installBundle).not.toHaveBeenCalled() }) @@ -1500,3 +1503,15 @@ it('reports restart-required row toggles and tolerates a vanished inventory entr await vi.waitFor(() => { expect(b.state().busy).toEqual([]) }) expect(b.track.mock.calls.filter(([name]) => name === 'plugin_toggle')).toHaveLength(1) }) + +it('reports a successful install with only documented result fields', async () => { + const b = bench() + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') }) + const results = b.track.mock.calls.filter(([name]) => name === 'install_plugin_result') + expect(results).toEqual([['install_plugin_result', { + input_value: 'dsh-new', is_success: true, duration: expect.any(Number) as number, plugin_name: 'dsh-new', + }]]) +}) diff --git a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx index 05b4110b10..99c1bc4d2a 100644 --- a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx +++ b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx @@ -106,7 +106,7 @@ export function DesktopOnboarding({
{step === 'welcome' && { click('next'); go('credit') }} />} {step === 'credit' && { click('next'); go('purpose') }} onRecharge={() => { click('charge'); recharge() }} onLater={() => { click('later'); later() }} />} + funded={state.creditFunded} canRecharge={canRecharge} onContinue={() => { click('continue'); go('purpose') }} onRecharge={() => { click('charge'); recharge() }} onLater={() => { click('later'); later() }} />} {step === 'purpose' && { void update({ purpose }) }} onContinue={() => { click('next'); purposeNext() }} />} {step === 'process' && {dialog !== null && { setDialog(null) }} onContinueSetup={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }} + onClose={() => { popupClick('close'); setDialog(null) }} onContinueSetup={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }} onSkip={() => { popupClick(dialog === 'skip' ? 'enter' : 'know'); void complete('skipped').then(() => { setDialog(null) }) }} />} } diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx index 1ee7324909..8152510505 100644 --- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx @@ -335,16 +335,16 @@ it('returns from purpose to credit without dropping the selected purpose', async }) -it('reports visible pages once, funded Continue, popup actions, and no close event', async () => { +it('reports visible pages once, funded Continue, and popup close', async () => { const b = mount('credit', 'positive', 'ready', zh, true) expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) }) expect(b.track).toHaveBeenCalledWith('onboarding_popup_view', { popup_name: 'skip_charge' }) b.track.mockClear() await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.close })) }) - expect(b.track).not.toHaveBeenCalled() + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_charge', button_name: 'close' }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) }) - expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'next' }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'continue' }) expect(b.track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_use_case' }) }) @@ -361,3 +361,11 @@ it('does not repeat page or popup exposure during save and refresh', () => { act(() => { b.changeStatus('ready') }) expect(b.track.mock.calls.filter(([name]) => name === 'onboarding_page_view')).toHaveLength(1) }) + +it('reports closing the skip-settings popup', () => { + const b = mount('purpose') + fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) + b.track.mockClear() + fireEvent.click(screen.getByRole('button', { name: zh.close })) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_setting', button_name: 'close' }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a17df31798..cdfd82df01 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -6005,7 +6005,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProductEventMap', - declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source?: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */', + declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\' | \'continue\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\' | \'close\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n msg_type: \'default\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: str /* …truncated — full shape in source */', }, { name: 'ProductTelemetryRecord',