diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index e486354a33..615270aae0 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -28,7 +28,11 @@ 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 = () => ({})') + writeFileSync(join(modules, 'dsh-app-boot', 'index.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'; 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/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ec85ddebce..93f5641fb1 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: b8e4f357cf330721 + zh: cdaaf35a061f41b1 /deepseek-harness-desktop/closing-the-window-and-quitting: en: c323e02e3f0305fe zh: 3c730db632136443 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 8109ebd577..cf780cddc6 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 live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. + The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. 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 f6c9667ad7..a64cbd2246 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 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 + 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cc0ce4f064..81c8059d55 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -75,6 +75,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/account-backend.ts b/apps/desktop/src/account-backend.ts index 889b4371bc..770d3e4e9d 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -67,9 +67,13 @@ export interface DesktopAccountBackend { * @param listener - state recipient. * @param failed - stream failure recipient. * @param expired - live credential-expiry recipient. + * @param onAnalyticsEnabledChanged - 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, + onAnalyticsEnabledChanged?: (enabled: boolean) => void, + ): () => void } /** @@ -86,19 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo state: () => call('getState'), start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }), - watch(listener, failed, expired) { + watch(listener, failed, expired, onAnalyticsEnabledChanged) { let closed = false let socket: WebSocket | undefined let retry: ReturnType | undefined const connect = (): void => { const streamId = randomUUID() const expiryStreamId = randomUUID() + const analyticsPolicyStreamId = randomUUID() void cookies().then((cookie) => { if (closed) return const url = new URL(REMOTE_STREAM_MUX_PATH, origin) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) socket.on('open', () => { + if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) }) @@ -106,6 +112,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 === analyticsPolicyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) + else onAnalyticsEnabledChanged?.(false) + return + } if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') if (frame.type === 'item') listener(accountView(frame.value)) @@ -113,11 +124,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) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) + }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() - return () => { closed = true; clearTimeout(retry); socket?.close() } + return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() } }, } } diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index 8fcde7768a..b367061732 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?.('auth_page_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?.('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])) { @@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) { - - + +

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

- +
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 9e4eef835c..a2d1cb38bf 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' @@ -229,13 +230,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 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 80f0d38930..8152510505 100644 --- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx @@ -10,6 +10,7 @@ afterEach(() => { 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. @@ -19,13 +20,15 @@ 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 }, }) - return copy[key]} complete={complete} retry={retry} + changeStatus = (value) => { setState(current => ({ ...current, status: value })) } + return copy[key]} complete={complete} retry={retry} update={async (change) => { setState(current => ({ ...current, status: 'saving' })) const saved = await update(change) @@ -38,7 +41,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal } render() return { - complete, openPlatformPage, release, update, retry, + complete, openPlatformPage, release, update, retry, track, changeStatus: (value: DesktopOnboardingState['status']) => { changeStatus(value) }, /** The viewer returning through the shared host's Back action. */ back: () => { returnFromPage?.() }, } @@ -330,3 +333,39 @@ 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, and popup close', async () => { + const b = mount('credit', 'positive', 'ready', zh, true) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) }) + expect(b.track).toHaveBeenCalledWith('onboarding_popup_view', { popup_name: 'skip_charge' }) + b.track.mockClear() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.close })) }) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_charge', button_name: 'close' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'continue' }) + 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() +}) + + +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) +}) + +it('reports closing the skip-settings popup', () => { + const b = mount('purpose') + fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) + b.track.mockClear() + fireEvent.click(screen.getByRole('button', { name: zh.close })) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_setting', button_name: 'close' }) +}) diff --git a/packages/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..c03b3613d8 100644 --- a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx @@ -178,3 +178,16 @@ 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) }) + +// Workspace login remains outside native welcome analytics even if a caller forwards a sender. +it('does not report workspace login views or login choices', async () => { + const track = vi.fn() + const props = { ...dialogProps(null), track } + const view = render() + view.rerender() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signIn })) }) + expect(props.start).toHaveBeenCalledOnce() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.addApiKey })) }) + expect(props.useApiKey).toHaveBeenCalledOnce() + expect(track).not.toHaveBeenCalled() +}) diff --git a/packages/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 3e3c096cab..dbd67087e4 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 ead2111e2b..c974239fed 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 8f8e0720fb..3ac2bb0480 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 3d39cc682d..169aac59b9 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 41dac376d1..cd6656fdd5 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 @@ -530,7 +532,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/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-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 d05d84036a..46a8755740 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 db76ce49d8..a6179cfad8 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 31267b804c..d941f983a4 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 799b7d9f54..b470973be5 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 15a26aac9f..bf8d40aa1d 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 43662a985d..7ac6134f4e 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 a24fc2689c..a9bd250a6d 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 9bd614dc4a..03698fd96c 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..2ce1c187cf 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. - * @returns completion after child creation and inherited-title increment. + * @param onCreated - observer before the optional child-title update. + * @returns the child SessionId after 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 531c93bab4..5d48fc495d 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/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 41e5ec6321..42d03757cd 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,10 +348,10 @@ 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.' }], - returns: 'completion after child creation and inherited-title increment.', + parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer before the optional child-title update.' }], + 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 04ea8d1140..83786448af 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.', + }, ], }, { @@ -1716,6 +1722,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'productAnalytics', + 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', + description: 'Read the collection policy.', + 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.', + 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.', @@ -5816,6 +5847,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;', @@ -5964,6 +5999,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 displayReason?: {\n readonly en: string;\n readonly [locale: string]: string;\n };\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\' | \'continue\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\' | \'close\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n msg_type: \'default\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: str /* …truncated — full shape in source */', + }, { name: 'ProductTelemetryRecord', declaration: 'export type ProductTelemetryRecord = OTelEventRecord;', diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml index 5c840f876c..6b65776e76 100644 --- a/packages/host/product-telemetry-otel/README.i18n.yaml +++ b/packages/host/product-telemetry-otel/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 9aa34fb1dc95d8cf /deepseek-ai-dsh-host-product-telemetry-otel/use-this-package: - en: 08e0bcbc259d131b - zh: 1d7cbde2688a903f + en: d2356d7abffcc0ce + zh: 13250317296816ce /deepseek-ai-dsh-host-product-telemetry-otel/understand-the-implementation: en: d7807324fdeb2b42 zh: 1bfe1e943e513500 diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md index f5b00d1496..09511b5094 100644 --- a/packages/host/product-telemetry-otel/README.md +++ b/packages/host/product-telemetry-otel/README.md @@ -25,7 +25,7 @@ Send selected product usage events to an OTLP/HTTP collector. Events carry a nam ## Use this package -Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The 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. The standalone example below reads `DSH_APP_VERSION`; Desktop supplies `DSH_CLIENT_VERSION` through its native launcher. Both must name the running release; the schema rejects an absent version. ```yaml - name: '@deepseek-ai/dsh-otel' diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md index 8d88254368..13fa44eee7 100644 --- a/packages/host/product-telemetry-otel/README.zh.md +++ b/packages/host/product-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。内置 profile 不挂载本插件。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 +在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。下方独立示例读取 `DSH_APP_VERSION`;桌面端由原生启动器提供 `DSH_CLIENT_VERSION`。两者均须为运行中的发布版本;缺少版本时 schema 会拒绝配置。 ```yaml - name: '@deepseek-ai/dsh-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 3b85740c8a..c15d57650e 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/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) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbf03c35ab..d7c476a630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -597,6 +597,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 @@ -1186,6 +1189,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 @@ -2395,6 +2401,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 @@ -2593,6 +2602,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 @@ -2714,6 +2726,9 @@ importers: specifier: ^11.0.0 version: 11.0.4 devDependencies: + '@deepseek-ai/dsh-otel': + specifier: workspace:* + version: link:../../telemetry/otel '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis @@ -2917,6 +2932,52 @@ importers: specifier: workspace:* version: link:../../util/package-manifest + packages/client/product-analytics: + dependencies: + '@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/dsh-otel': + specifier: workspace:* + version: link:../../telemetry/otel + '@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 + '@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': @@ -3197,6 +3258,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 @@ -3387,6 +3451,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 @@ -3967,6 +4034,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 @@ -4244,6 +4314,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 @@ -4630,6 +4703,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 @@ -4812,6 +4888,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 @@ -5103,6 +5182,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 @@ -6038,6 +6120,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 b62ff2606a..582358bf60 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -54,6 +54,7 @@ export const SERVICE_PAGE: Record = { speechController: 'voice-input.md', otel: 'otel.md', productTelemetry: 'product-telemetry.md', + productAnalytics: 'product-telemetry.md', connection: 'web-server.md', pluginManager: 'boot.md', pluginRegistryProbe: 'boot.md', @@ -834,6 +835,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 fe644b7a10..3b42ffdde0 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 live Host collection policy.', + }, { key: 'otel', pkg: 'otel', diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 8c994bf50b..e0717dae76 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -363,6 +363,7 @@ syncBuiltinESMExports() const result = await runInstaller(fixture, fixture.main, {}, ['--require', preload]) + expect(existsSync(probe), `lock injection missing: exit ${result.status}\n${result.stderr}`).toBe(true) expect(readFileSync(probe, 'utf8')).toBe('injected') const recovers = process.platform === 'win32' && code === 'EPERM' && !expires expect(result.status, result.stderr).toBe(recovers ? 0 : 1) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 900f0300b5..da3427d48c 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 60d4d66712..750f340b12 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-otel": ["./packages/telemetry/otel/src"], "@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"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2e27307ddb..926d792743 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -50,6 +50,7 @@ "packages/client/*/tests/**/*.host.spec.ts" ], "references": [ + { "path": "./packages/client/product-analytics/tsconfig.client.json" }, { "path": "./apps/desktop/tsconfig.client.json" }, { "path": "./packages/util/atomic-write" }, { "path": "./packages/experimental/client-ui-voice-input" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 68e4bcc4ed..74a449f60c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -198,6 +198,7 @@ "scripts/client-bundle-purity.spec.ts" ], "references": [ + { "path": "./packages/client/product-analytics/tsconfig.host.json" }, { "path": "./packages/telemetry/otel" }, { "path": "./packages/experimental/speech-to-text" }, { "path": "./packages/experimental/speech-to-text-sensevoice" },