From b1cf871b2549a4384a668b8856417e843be1a496 Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 17:08:31 +0800 Subject: [PATCH 01/45] 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/45] 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/45] 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/45] 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 7e5a4d1e4b517ffd97d74d90201daec2306bb372 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 23:56:45 +0800 Subject: [PATCH 05/45] fix(docs): align publication runner and scoped-event heap budgets --- .github/workflows/docs-pages.yml | 2 +- package.json | 4 ++-- scripts/gen-scoped-events.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 8bac404b2d..6cd25b059d 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -35,7 +35,7 @@ env: jobs: build: - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read pages: read diff --git a/package.json b/package.json index 81a370d4c4..4317f7dc94 100644 --- a/package.json +++ b/package.json @@ -181,8 +181,8 @@ "gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts", "verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", - "gen-scoped-events": "tsx scripts/gen-scoped-events.ts", - "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check", + "gen-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts", + "verify-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts --check", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", diff --git a/scripts/gen-scoped-events.ts b/scripts/gen-scoped-events.ts index 9d070def72..eecf684d8b 100644 --- a/scripts/gen-scoped-events.ts +++ b/scripts/gen-scoped-events.ts @@ -9,8 +9,8 @@ * `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`; * multiple matches are ambiguous and always fail loud. * - * `tsx scripts/gen-scoped-events.ts` -> write the generated source - * `tsx scripts/gen-scoped-events.ts --check` -> exit 1 when it is stale + * `pnpm run gen-scoped-events` -> write the generated source + * `pnpm run verify-scoped-events` -> exit 1 when it is stale */ import { existsSync, readFileSync, writeFileSync } from 'node:fs' From 197355bb12584f99595cd4ac5f2268f231e9f176 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:08:48 -0700 Subject: [PATCH 06/45] fix(schedule-bundle): name the Plugins row Automation tasks with an alarm clock icon The row now carries the name of the sidebar entry it adds and a plain-language description, and its icon is an alarm clock drawn in the Official group's 36px style. The README and the user guide quote the new title; the README names the sidebar entry instead of a right-sidebar page. --- .../tests/expected/plugin-config/official.expected.md | 6 +++--- .../expected/plugin-manager/live-enabled.expected.md | 6 +++--- .../tests/expected/plugin-manager/manager.expected.md | 6 +++--- docs/user/guide/schedule.i18n.yaml | 4 ++-- docs/user/guide/schedule.md | 2 +- docs/user/guide/schedule.zh.md | 2 +- packages/experimental/schedule-bundle/README.i18n.yaml | 4 ++-- packages/experimental/schedule-bundle/README.md | 2 +- packages/experimental/schedule-bundle/README.zh.md | 2 +- packages/experimental/schedule-bundle/icon.svg | 9 +++++---- packages/experimental/schedule-bundle/locale/en.json | 4 ++-- packages/experimental/schedule-bundle/locale/zh.json | 4 ++-- 12 files changed, 26 insertions(+), 25 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 247d6ac308..dddc056ff2 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -14,9 +14,9 @@ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" - listitem: - - button "查看 定时与时间上下文": 定时与时间上下文 - - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 - - switch "启用 定时与时间上下文" + - button "查看 自动化任务": 自动化任务 + - text: 实验性 按设定的时间或周期,在会话中自动执行任务。 + - switch "启用 自动化任务" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 4bd8ed064e..086bfee63c 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -14,9 +14,9 @@ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" - listitem: - - button "查看 定时与时间上下文": 定时与时间上下文 - - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 - - switch "启用 定时与时间上下文" + - button "查看 自动化任务": 自动化任务 + - text: 实验性 按设定的时间或周期,在会话中自动执行任务。 + - switch "启用 自动化任务" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index b188be8787..90f137a80e 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -14,9 +14,9 @@ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" - listitem: - - button "查看 定时与时间上下文": 定时与时间上下文 - - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 - - switch "启用 定时与时间上下文" + - button "查看 自动化任务": 自动化任务 + - text: 实验性 按设定的时间或周期,在会话中自动执行任务。 + - switch "启用 自动化任务" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml index 1bf88dec7c..1aa4d2c185 100644 --- a/docs/user/guide/schedule.i18n.yaml +++ b/docs/user/guide/schedule.i18n.yaml @@ -12,8 +12,8 @@ en: b3b2a6e0ce02bdee zh: 2ddb168de79fd19c /schedule-reminders/create-reminders: - en: c76ee99f59529942 - zh: be1d75c226231dd7 + en: 75d55fa3e4fc01d5 + zh: 2fb76166a9106f5f /schedule-reminders/inspect-and-delete-tasks: en: a44fc1aabc2ab957 zh: 31f444d4c7fd5c7a diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md index abb86b7f8e..e248cccdb5 100644 --- a/docs/user/guide/schedule.md +++ b/docs/user/guide/schedule.md @@ -17,7 +17,7 @@ Create reminders in a conversation, then inspect active and inactive tasks and e ## Create reminders -The shipped Web profile carries the `time-context`, `schedule`, and `ui-schedule` rows disabled. Open Plugins and enable `Scheduling and time context` in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders. +The shipped Web profile carries the `time-context`, `schedule`, and `ui-schedule` rows disabled. Open Plugins and enable `Automation tasks` in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders. Ask the model to create, list, edit, or delete reminders. It uses `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` (update changes one reminder in place and keeps its id and saved delivery records); the Automation tasks page's New action opens a New Session for a creation instead, with the request already written in its composer. Supported choices are a one-time delay in positive whole seconds, an absolute date and time, a fixed interval of at least one minute, a daily local time with an IANA time zone, a weekly local time with an IANA time zone and ISO weekdays from Monday 1 through Sunday 7, or a five-field cron expression with an explicit IANA time zone, stored in canonical form. diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md index bd10a06fe5..4b2a1827f3 100644 --- a/docs/user/guide/schedule.zh.md +++ b/docs/user/guide/schedule.zh.md @@ -17,7 +17,7 @@ ## 创建提醒 -发布的 Web profile 把 `time-context`、`schedule` 与 `ui-schedule` 三行保持禁用。打开插件管理页,在 Official 分组中启用“定时与时间上下文”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。 +发布的 Web profile 把 `time-context`、`schedule` 与 `ui-schedule` 三行保持禁用。打开插件管理页,在“官方”分组中启用“自动化任务”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。 向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少一分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。 diff --git a/packages/experimental/schedule-bundle/README.i18n.yaml b/packages/experimental/schedule-bundle/README.i18n.yaml index 98d86454bc..f7efa8d5ab 100644 --- a/packages/experimental/schedule-bundle/README.i18n.yaml +++ b/packages/experimental/schedule-bundle/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 364c80ee00a374ff /deepseek-ai-dsh-experimental-schedule-bundle/use-this-package: - en: ffaa4b18e10a1df9 - zh: b675fd8195ebb057 + en: a5fcc3d26604ff4c + zh: bc358d33b0b0c1d2 /deepseek-ai-dsh-experimental-schedule-bundle/understand-the-implementation: en: 90beaeda691bd0a7 zh: d81683d7bb1f6903 diff --git a/packages/experimental/schedule-bundle/README.md b/packages/experimental/schedule-bundle/README.md index 6e3abd4eea..6bd93ce427 100644 --- a/packages/experimental/schedule-bundle/README.md +++ b/packages/experimental/schedule-bundle/README.md @@ -25,7 +25,7 @@ This optional bundle switches on the three Schedule rows that `@deepseek-ai/dsh- ## Use this package -Open Plugins in the Web sidebar and enable Scheduling and time context, marked by a clock icon. A live root Agent then receives `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`, the Session header shows its reminder catalog, the right sidebar shows the Automation tasks page, and each eligible step appends one clock reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. Disabling the bundle restores the shipped composition; stored tasks remain on disk. +Open Plugins in the Web sidebar and enable Automation tasks, marked by an alarm clock icon. A live root Agent then receives `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`, the Session header shows its reminder catalog, the sidebar shows the Automation tasks entry, and each eligible step appends one clock reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. Disabling the bundle restores the shipped composition; stored tasks remain on disk. ----- diff --git a/packages/experimental/schedule-bundle/README.zh.md b/packages/experimental/schedule-bundle/README.zh.md index 747137bc57..d72e8434a3 100644 --- a/packages/experimental/schedule-bundle/README.zh.md +++ b/packages/experimental/schedule-bundle/README.zh.md @@ -25,7 +25,7 @@ kind: "package-bundle" ## 使用此包 -打开 Web 侧栏的插件管理页并启用带时钟图标的“定时与时间上下文”。此后,活跃的根智能体获得 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`,会话头部显示提醒目录,右侧栏显示自动化任务页面,每个符合条件的步骤追加一条时钟读数,包含当前时间、打开请求所带的浏览器时区,以及距上一条模型可见消息的经过时间。禁用此 Bundle 会恢复随包组合;已存储的任务保留在磁盘上。 +打开 Web 侧栏的插件管理页并启用带闹钟图标的“自动化任务”。此后,活跃的根智能体获得 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`,会话头部显示提醒目录,侧栏出现“自动化任务”入口,每个符合条件的步骤追加一条时钟读数,包含当前时间、打开请求所带的浏览器时区,以及距上一条模型可见消息的经过时间。禁用此 Bundle 会恢复随包组合;已存储的任务保留在磁盘上。 ----- diff --git a/packages/experimental/schedule-bundle/icon.svg b/packages/experimental/schedule-bundle/icon.svg index 4299a86395..91a374ce9f 100644 --- a/packages/experimental/schedule-bundle/icon.svg +++ b/packages/experimental/schedule-bundle/icon.svg @@ -1,9 +1,10 @@ - - - + + + + - + diff --git a/packages/experimental/schedule-bundle/locale/en.json b/packages/experimental/schedule-bundle/locale/en.json index f748924183..8766ee2a4c 100644 --- a/packages/experimental/schedule-bundle/locale/en.json +++ b/packages/experimental/schedule-bundle/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Scheduling and time context", - "description": "Enable durable reminders, the Automation tasks page, and per-step clock context." + "title": "Automation tasks", + "description": "Run tasks in your sessions at a set time or on a repeating schedule." } } diff --git a/packages/experimental/schedule-bundle/locale/zh.json b/packages/experimental/schedule-bundle/locale/zh.json index 47abb9b169..14e171d352 100644 --- a/packages/experimental/schedule-bundle/locale/zh.json +++ b/packages/experimental/schedule-bundle/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { - "title": "定时与时间上下文", - "description": "启用持久提醒、自动化任务页面与逐步时钟上下文。" + "title": "自动化任务", + "description": "按设定的时间或周期,在会话中自动执行任务。" } } From 92690a5a3bd96bc36c281eb3d7d347ee971d3e61 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:14:40 -0700 Subject: [PATCH 07/45] fix(ui-settings-plugin-inventory): align and simplify plugin cards Cards in one grid row now share a height, with title rows and descriptions starting at the same offsets. The entry id shows only when it differs from the title, as a code chip in the card's bottom-left corner; tags mark only states other than plainly enabled; descriptions clamp to two lines until the card expands; the preset group starts open; loading shows skeleton cards. Group headers share one height, the subtitle separator is evenly spaced, the title weight drops to 500, 12.5px text moves to 12px, and a container query on the tab switches the grid to one column. --- .../README.i18n.yaml | 16 +-- .../ui-settings-plugin-inventory/README.md | 10 +- .../ui-settings-plugin-inventory/README.zh.md | 10 +- .../PluginInventorySettingsTab.module.css | 135 +++++++++++++----- .../src/client/PluginInventorySettingsTab.tsx | 57 +++++--- .../src/client/locales.ts | 2 +- .../tests/components.client.spec.tsx | 83 ++++++----- 7 files changed, 204 insertions(+), 109 deletions(-) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 6b7ff6a2bf..b69016100b 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -9,8 +9,8 @@ en: 463eecf002ec0f54 zh: b8dc6003ddccd14a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/summary: - en: 56f5349b6e502525 - zh: 691a0fe90b739e9d + en: f52353cf99816eb8 + zh: e172c1751289882f /deepseek-ai-dsh-client-ui-settings-plugin-inventory/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,14 +18,14 @@ en: 67637808e7940157 zh: d4d0716ea4d33e4a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/reading-a-card: - en: e615aaa891b5055f - zh: 38769e763c8e54ca + en: 6ccd8a53e3205f24 + zh: 1d0ad59bfb7786e4 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/the-preset-switcher: en: 84e5938329199a22 zh: 6f5cfaac56433e8b /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/retrying-a-failed-read: - en: 2859449aaae5ab59 - zh: 06380059ae429026 + en: 673c7574e14078be + zh: b093e897f0341973 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation: en: cab883d26642c932 zh: 54e7321bd781b400 @@ -33,8 +33,8 @@ en: e4792880c5916ce0 zh: 4c8171a446dd153f /deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation/rendering: - en: b9aa850e5f101b68 - zh: 8a29025f19f6ce37 + en: 779146e3d3732803 + zh: 0dfafa328dfeda75 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/further-exploration: en: ae3819f0c5da95bd zh: 40972ab96d5c95e2 diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index 7353f14d11..d6127c9ed9 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. +The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, open by default, then the collapsed global inventory; a search opens both. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. ## Table of Contents @@ -31,9 +31,9 @@ A failed installation with pending pnpm build permissions offers **Allow these s ### Reading a card -Each collapsed card uses the plugin's localized title and description when available. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. +Each collapsed card uses the plugin's localized title and description when available; a description shows at most two lines until its card is expanded. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. -The stable entry id appears underneath the title, alongside a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. +A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. The chip's hover text, search, and expanded details retain the complete id, and so does the accessible name unless the id equals the title. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. ### The preset switcher @@ -41,7 +41,7 @@ The switcher is the same selector-pill-plus-menu control the General settings ro ### Retrying a failed read -A failed read renders the shared error marker inside the tab; loading and current-page synchronization use the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details. +A failed read renders the shared error marker inside the tab. Loading shows skeleton cards in the card grid, and current-page synchronization uses the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details. The Plugin list also shows synchronization failures on the current page. Its retry reapplies the latest client graph without changing Host enablement or refreshing the page. @@ -61,7 +61,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w ### Rendering -Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and supplies the collapsed subtitle after removal of a leading composition `include:` marker, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. +Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and, after removal of a leading composition `include:` marker, supplies the collapsed id chip when the result differs from the title, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 9078f8074a..a084d93919 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 +**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;预设分组默认展开,全局分组默认收起,搜索期间两组都展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 ## 目录 @@ -31,9 +31,9 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用插件可用的本地化标题与描述。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 +每张收起的卡片使用插件可用的本地化标题与描述;描述最多显示两行,展开卡片后显示全文。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 -稳定的条目 id 显示在标题下方,卡片带有表示启停状态的小标签;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 +表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。代码标签的悬停提示、搜索与展开详情保留完整 id;除非 id 与标题相同,无障碍名称也保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 ### 预设切换器 @@ -41,7 +41,7 @@ kind: "package-reference" ### 重试失败的读取 -读取失败会在标签页内渲染共享 error 标记;加载与当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。 +读取失败会在标签页内渲染共享 error 标记。加载时在卡片网格中显示骨架卡片,当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。 插件列表还显示当前页面的同步失败。重试会重新应用最新客户端图,不改变 Host 启用状态,也不刷新页面。 @@ -61,7 +61,7 @@ kind: "package-reference" ### 渲染 -行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中,并在去掉开头的组合 `include:` 标记后作为收起次标题,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 +行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中;去掉开头的组合 `include:` 标记后若与标题不同,则作为收起卡片的 id 代码标签,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 87b29d442f..1d732ae93d 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -1,4 +1,6 @@ +/* The tab's own width, not the window's, decides the card columns below. */ .section { + container: plugin-inventory / inline-size; display: flex; flex-direction: column; gap: 14px; @@ -7,7 +9,6 @@ color: var(--dsw-alias-label-primary); } -.catalogHeading h3, .status, .failure p { margin: 0; @@ -85,37 +86,21 @@ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)) 18%, transparent); } -.catalogHeading { - display: flex; - align-items: baseline; - gap: 7px; - padding: 0 2px; -} - -.catalogHeading h3 { - font-size: 13px; - line-height: 20px; - font-weight: 600; -} - -.catalogHeading span { - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); - font-variant-numeric: tabular-nums; -} - .cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: start; gap: 10px; margin: 0; padding: 0; list-style: none; } +/* Cards stretch to their grid row's height. The button fills a stretched card: + the title row and description stay at the top, in line with the other card's, + and the spare height sits above the entry id chip or at the bottom. */ .card { + display: flex; + flex-direction: column; min-width: 0; overflow: hidden; border: 0.5px solid var(--dsw-alias-settings-card-stroke); @@ -127,11 +112,21 @@ border-color: var(--dsw-alias-border-l3); } +/* The other card in an expanded card's row keeps its own height instead of + stretching alongside the details. With two columns each row starts at an odd + position; in one column the matched card sits alone in its row, unaffected. */ +.card:nth-child(odd)[data-open='true'] + .card, +.card:nth-child(odd):has(+ .card[data-open='true']) { + align-self: start; +} + .cardContent { box-sizing: border-box; display: flex; + flex: 1 1 auto; flex-direction: column; align-items: stretch; + gap: 2px; width: 100%; min-height: 52px; border: 0; @@ -173,11 +168,35 @@ min-width: 0; font-size: 14px; line-height: 20px; - font-weight: 600; + font-weight: 500; } -/* The Loader entry id keeps one size in the card subtitle and the row it expands into; - the collapsed subtitle stays on the quieter label tier, while the expanded row carries +/* Two lines at most while collapsed; the expanded card shows the full text. */ +.cardDescription { + display: -webkit-box; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + text-wrap: pretty; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.card[data-open='true'] .cardDescription { + display: block; +} + +/* The entry id is metadata, not prose: a code chip in the card's bottom-left + corner whose left edge lines up with the title. */ +.cardMeta { + display: flex; + margin-top: auto; + padding-top: 6px; +} + +/* The Loader entry id keeps one size in the card chip and the row it expands into; + the collapsed chip stays on the quieter label tier, while the expanded row carries the card's primary value. */ .cardIdentity, .entryValue { @@ -188,15 +207,19 @@ .cardIdentity { display: block; - width: 100%; - color: var(--dsw-alias-label-tertiary); + box-sizing: border-box; + max-width: 100%; + border-radius: var(--dsw-radius-xs); + padding: 1px 6px; + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-secondary); } .cardTrailing { display: inline-flex; flex: none; align-items: center; - gap: 7px; + gap: 8px; color: var(--dsw-alias-label-tertiary); } @@ -213,11 +236,13 @@ gap: 10px; } +/* Every group header takes the preset switcher's 36px height, so each subtitle + sits the same distance below its title. */ .groupTitleRow { display: flex; align-items: center; gap: 8px; - min-height: 32px; + min-height: 36px; } .group + .group { @@ -266,7 +291,7 @@ .groupSub { display: flex; flex-wrap: wrap; - gap: 4px 8px; + row-gap: 4px; margin: -6px 0 0 20px; color: var(--dsw-alias-label-tertiary); font-size: 12px; @@ -274,6 +299,13 @@ font-variant-numeric: tabular-nums; } +/* A middle dot with equal space on both sides separates the subtitle's parts. */ +.groupSub > * + *::before { + content: '·'; + padding: 0 6px; + color: var(--dsw-alias-label-tertiary); +} + .failedCount { color: var(--dsw-alias-state-error-primary); } @@ -330,7 +362,7 @@ padding: 8px 10px; background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); color: var(--dsw-alias-state-error-primary); - font-size: 12.5px; + font-size: 12px; line-height: 18px; overflow-wrap: anywhere; white-space: pre-line; @@ -343,7 +375,7 @@ gap: 4px 8px; margin: 0; color: var(--dsw-alias-label-tertiary); - font-size: 12.5px; + font-size: 12px; line-height: 18px; } @@ -353,10 +385,35 @@ background: transparent; color: var(--dsw-alias-state-business-primary); font: inherit; - font-size: 12.5px; + font-size: 12px; cursor: pointer; } +/* Loading placeholder in the cards grid, sized like a card with one description + line; its bars pulse through the shared skeleton token as the input-trigger + menu's pending rows do. */ +.skeletonCard { + display: flex; + flex-direction: column; + gap: 8px; + border: 0.5px solid var(--dsw-alias-settings-card-stroke); + border-radius: var(--dsw-radius-xl); + padding: 15px 14px; + background: var(--dsw-alias-settings-card-fill); +} + +.skeletonBar { + width: 40%; + height: 14px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-bg-skeleton); +} + +.skeletonBar + .skeletonBar { + width: 80%; + height: 12px; +} + .enabledIn { display: flex; @@ -430,9 +487,19 @@ .chevron { transition: transform 140ms var(--ds-ease-in-out); } + + .skeletonBar { + animation: plugin-inventory-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; + } } -@media (max-width: 680px) { +@keyframes plugin-inventory-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } +} + +@container plugin-inventory (max-width: 520px) { .cards { grid-template-columns: minmax(0, 1fr); } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index c5064e5441..a86672f79f 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -58,6 +58,9 @@ const PHASE_KEYS = { unloading: 'unloading', } satisfies Record, PluginInventoryLocaleKey> +/** Placeholder cards the loading skeleton lays out in the cards grid. */ +const SKELETON_CARDS = [0, 1, 2, 3] as const + /** Localized accessible label for one root Fiber phase. */ function phaseLabel(phase: PluginFiberPhase, t: Translate): string { return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) @@ -77,6 +80,11 @@ function entrySubtitle(entryId: string): string { return entryId.replace(/^include:/, '') } +/** Accessible card name: the title, the entry id unless it repeats the title, then the enablement state. */ +function cardLabel(title: string, entryId: string | null, state: string): string { + return [title, ...entryId === null || entryId === title ? [] : [entryId], state].join(', ') +} + /** Preserve translated titles and shorten literal package or module name fallbacks in Settings. */ function pluginText(row: PluginInventoryEntry | AgentPresetRow, resolveText: PluginInventorySettingsTabInjected['resolveText']) { const title = row.meta?.title @@ -127,6 +135,7 @@ function PluginCard({ const open = expanded === rowKey const detailId = `plugin-details-${encodeURIComponent(rowKey)}` const descriptionId = useId() + const subtitle = entryId === null ? null : entrySubtitle(entryId) return (