refactor(analytics): centralize live policy and message submission context

This commit is contained in:
zhc committed 2026-09-24 22:58:55 +08:00
1 parent e29a880027
commit 9f7ef18c03
73 files changed
+605 -281

No files matched your search

-2
View File
@@ -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 = () => ({});
+1 -2
View File
@@ -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<void> {
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'),
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 覆盖。
+13 -5
View File
@@ -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<typeof setTimeout> | 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() }
},
}
}
+6 -6
View File
@@ -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 }) {
<button id="auth-loading" className="primary" type="button" hidden={failed} disabled aria-label={m.welcomeAuthExchanging}>
<StateDot state="ongoing" size={16} className="welcome-loading" />
</button>
<button id="auth-retry" className="primary" type="button" hidden={!failed} onClick={() => { void api.analytics?.('sign_in'); void start() }}>{m.welcomeAuthRetry}</button>
<button id="auth-api-key" className="secondary" type="button" hidden={!failed} onClick={() => { void api.analytics?.('api-key'); navigate('key') }}>{m.welcomeApiKey}</button>
<button id="auth-retry" className="primary" type="button" hidden={!failed} onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'sign_in' }); void start() }}>{m.welcomeAuthRetry}</button>
<button id="auth-api-key" className="secondary" type="button" hidden={!failed} onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'api-key' }); navigate('key') }}>{m.welcomeApiKey}</button>
<button id="auth-cancel" className="secondary" type="button" hidden={failed}
disabled={cancelling || phase === 'committing' || phase === 'succeeded' || (phase === 'initializing' && !attempt?.id)}
onClick={() => { void cancel() }}>{m.welcomeAuthCancel}</button>
</div>
<div id="entry-actions" className="actions" hidden={page !== 'entry'}>
<button id="sign-in" className="primary" type="button" onClick={() => { void api.analytics?.('sign_in'); void start() }}>{m.welcomeSignIn}</button>
<button ref={keyButton} id="api-key" className="secondary" type="button" onClick={() => { void api.analytics?.('api-key'); navigate('key') }}>{m.welcomeApiKey}</button>
<button id="sign-in" className="primary" type="button" onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'sign_in' }); void start() }}>{m.welcomeSignIn}</button>
<button ref={keyButton} id="api-key" className="secondary" type="button" onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'api-key' }); navigate('key') }}>{m.welcomeApiKey}</button>
</div>
<div id="key-actions" className="actions" hidden={page !== 'key'}>
<button id="save-key" className="primary" type="submit" form="key-form" disabled={busy || draft.trim() === ''}>{m.welcomeKeySave}</button>
+10 -6
View File
@@ -1,4 +1,3 @@
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. */
@@ -379,11 +378,12 @@ async function main(): Promise<void> {
let injections: readonly unknown[] = []
let welcomeBackend: DesktopWelcomeBackend | undefined
let reportedLaunch = false
const analyticsEnabled = resolveLaunchFlag('DSH_PRODUCT_ANALYTICS_ENABLED', process.env.DSH_PRODUCT_ANALYTICS_ENABLED, true)
let analyticsEnabled = false
const track = async <K extends keyof ProductEventMap>(eventName: K, attributes: ProductEventMap[K]): Promise<void> => {
if (!analyticsEnabled) return
const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent
try { await welcomeBackend?.report(event) } catch (_error) { /* Analytics cannot interrupt native actions. */ }
try {
if (analyticsEnabled) await welcomeBackend?.report(event)
} catch (_error) { /* Analytics cannot interrupt native actions. */ }
}
let stopAccount: (() => void) | undefined
let openedAttempt: string | undefined
@@ -428,6 +428,7 @@ async function main(): Promise<void> {
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
injections = ready.injections
welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false)
if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) }
stopAccount?.()
const accountBackend = welcomeBackend.account
@@ -466,9 +467,11 @@ async function main(): Promise<void> {
const state = await accountBackend.state()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
}).catch(() => undefined)
})
}, (enabled) => { analyticsEnabled = enabled })
},
stop: async () => {
analyticsEnabled = false
stopAccount?.()
try { await host.stop(requireCleanStop) }
catch (error) {
if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
@@ -1104,7 +1107,8 @@ async function main(): Promise<void> {
}
openingWelcome ??= (async () => {
welcomeWindow = await openWelcomeWindow(locale, {
...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 }) } : {},
analytics: track,
analyticsEnabled: () => Promise.resolve(analyticsEnabled),
takeNotice: () => {
const notice = pendingWelcomeNotice
pendingWelcomeNotice = undefined
+4 -1
View File
@@ -10,7 +10,10 @@ const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice
if (locale === undefined) throw new Error('desktop welcome: missing window locale')
const api: WelcomeApi = {
...resolveDesktopLocale(locale),
analytics: action => process.argv.includes('--dsh-product-analytics') ? ipcRenderer.invoke(WELCOME_IPC.analytics, action) as Promise<void> : Promise.resolve(),
analytics: async (eventName, attributes) => {
if (await api.analyticsEnabled()) await ipcRenderer.invoke(WELCOME_IPC.analytics, eventName, attributes)
},
analyticsEnabled: () => ipcRenderer.invoke(WELCOME_IPC.analyticsEnabled) as Promise<boolean>,
takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise<WelcomeNotice | undefined>,
startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise<AccountView>,
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise<AccountView>,
+8 -2
View File
@@ -1,12 +1,14 @@
/** Operations available to the isolated native welcome renderer. */
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/types'
import type { DesktopLocale } from './locale.ts'
/** Private native welcome channels, installed only while its window exists. */
export const WELCOME_IPC = {
saveApiKey: 'dsh-welcome:save-api-key',
analytics: 'dsh-welcome:analytics',
analyticsEnabled: 'dsh-welcome:analytics-enabled',
skip: 'dsh-welcome:skip',
start: 'dsh-welcome:start',
cancel: 'dsh-welcome:cancel',
@@ -21,10 +23,14 @@ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false }
/** One-time notification retained by the main process until Welcome receives it. */
export type WelcomeNotice = 'session-expired'
type WelcomeEventName = 'auth_page_view' | 'auth_page_click' | 'api_key_save_click'
/** Host-owned operations used by the welcome window. */
export interface WelcomeOperations {
/** @param action - fixed welcome gesture; never carries credential contents. */
analytics?(action: 'sign_in' | 'api-key' | 'save-key' | 'view'): Promise<void>
/** @param eventName - allowed welcome event. @param attributes - approved fields without credentials. */
analytics?<K extends WelcomeEventName>(eventName: K, attributes: ProductEventMap[K]): Promise<void>
/** @returns the Host's current effective collection policy. */
analyticsEnabled(): Promise<boolean>
/** @returns the pending notification, clearing it before another renderer can receive it. */
takeNotice(): Promise<WelcomeNotice | undefined>
/** @returns account state after starting a login attempt. */
+7
View File
@@ -14,6 +14,8 @@ export interface WelcomeState {
/** Narrow operations available to the native welcome flow. */
export interface DesktopWelcomeBackend {
/** @returns the current Host policy; every read observes live configuration. */
analyticsEnabled(): Promise<boolean>
readonly account: DesktopAccountBackend
/** @param event - desktop-owned fields. @returns after local Host intake. */
report(event: ProductEvent): Promise<void>
@@ -121,6 +123,11 @@ export async function connectDesktopWelcome(
return {
account,
read,
async analyticsEnabled() {
const enabled = await invoke({ namespace: 'productAnalytics', method: 'enabled', args: {} }, AbortSignal.timeout(1000))
if (typeof enabled !== 'boolean') throw new Error('desktop analytics: invalid collection policy')
return enabled
},
async report(event) { await invoke({ namespace: 'productAnalytics', method: 'report', args: { event } }, AbortSignal.timeout(1000)) },
async readLocalePreference() {
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
+14 -8
View File
@@ -58,9 +58,6 @@ let disposeActiveHandlers: (() => void) | undefined
*/
export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise<BrowserWindow> {
const options = welcomeWindowOptions(process.platform, locale)
if (operations.analytics !== undefined && options.webPreferences?.additionalArguments) {
options.webPreferences.additionalArguments.push('--dsh-product-analytics')
}
const window = new BrowserWindow(options)
disposeActiveHandlers?.()
let active = true
@@ -68,7 +65,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
if (!active) return
active = false
for (const channel of [
WELCOME_IPC.analytics, WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey,
WELCOME_IPC.analyticsEnabled, WELCOME_IPC.analytics, WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey,
WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
]) {
ipcMain.removeHandler(channel)
@@ -81,10 +78,19 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
throw new Error('desktop welcome: rejected action from an unowned frame')
}
}
ipcMain.handle(WELCOME_IPC.analytics, async (event, action: unknown) => {
ipcMain.handle(WELCOME_IPC.analyticsEnabled, (event) => {
assertSender(event)
if (action !== 'sign_in' && action !== 'api-key' && action !== 'save-key' && action !== 'view') throw new Error('desktop welcome: invalid analytics action')
await operations.analytics?.(action)
return operations.analyticsEnabled()
})
ipcMain.handle(WELCOME_IPC.analytics, async (event, eventName: unknown, attributes: unknown) => {
assertSender(event)
if (typeof attributes !== 'object' || attributes === null || Array.isArray(attributes)) throw new Error('desktop welcome: invalid analytics attributes')
if (eventName === 'auth_page_click' && 'button_name' in attributes && Object.keys(attributes).length === 1
&& (attributes.button_name === 'sign_in' || attributes.button_name === 'api-key')) {
await operations.analytics?.(eventName, { button_name: attributes.button_name })
} else if ((eventName === 'auth_page_view' || eventName === 'api_key_save_click') && Object.keys(attributes).length === 0) {
await operations.analytics?.(eventName, {})
} else throw new Error('desktop welcome: invalid analytics event')
})
ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() })
ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => {
@@ -120,7 +126,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()
void operations.analytics?.('view')
void operations.analytics?.('auth_page_view', {})
}
return window
}
+12 -3
View File
@@ -49,14 +49,15 @@ it('receives live expiry separately from account snapshots', async () => {
if (typeof address === 'string' || address === null) throw new Error('missing server address')
const connected = once(server, 'connection')
const expired = vi.fn()
const policy = vi.fn()
const snapshot = { status: 'signed-out', attempt: null,
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
const received = Promise.withResolvers<undefined>()
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve(''))
const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired)
const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, policy)
onTestFinished(stop)
await connected
// Both logical streams share the socket; observe their opening frames before sending data.
// Logical streams share the socket; observe their opening frames before sending data.
const streams = new Map<string, string>()
const opened = Promise.withResolvers<undefined>()
const socket = [...server.clients][0]!
@@ -64,11 +65,19 @@ it('receives live expiry separately from account snapshots', async () => {
if (!Buffer.isBuffer(data)) throw new Error('expected a Buffer WebSocket frame')
const frame = JSON.parse(data.toString('utf8')) as { endpoint: string; streamId: string }
streams.set(frame.endpoint, frame.streamId)
if (streams.size === 2) opened.resolve(undefined)
if (streams.size === 3) opened.resolve(undefined)
})
await opened.promise
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot }))
await received.promise
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(true) })
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: false }))
await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(false) })
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(true) })
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: 'invalid policy' }))
await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(false) })
expect(expired).not.toHaveBeenCalled()
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' }))
await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() })
+13 -5
View File
@@ -172,6 +172,8 @@ const harness = await vi.hoisted(async () => {
}),
})
let accountListener: ((state: AccountView) => void) | undefined
let analyticsEnabled = true
let policyListener: ((enabled: boolean) => void) | undefined
const analytics = vi.fn(async (_event: unknown) => {})
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
const trays: FakeTray[] = []
@@ -191,9 +193,14 @@ const harness = await vi.hoisted(async () => {
platformDispose,
platformCloseAndWait,
watchAccount: (listener: (state: AccountView) => void) => {
get analyticsEnabled() { return analyticsEnabled },
set analyticsEnabled(value: boolean) { analyticsEnabled = value; policyListener?.(value) },
watchAccount: (
listener: (state: AccountView) => void, _failed: () => void, _expired: () => void, policy?: (enabled: boolean) => void,
) => {
policyListener = policy
accountListener = listener
return () => { accountListener = undefined }
return () => { accountListener = undefined; policyListener = undefined }
},
publishAccount(state: AccountView) { accountListener?.(state) },
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
@@ -345,6 +352,7 @@ vi.mock('../src/platform-view.ts', async importOriginal => ({
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
analyticsEnabled: async () => harness.analyticsEnabled,
report: harness.analytics,
readLocalePreference: async () => null,
read: async (): Promise<unknown> => (await harness.hosts.at(-1)!.fetch()).json() as Promise<unknown>,
@@ -399,7 +407,7 @@ beforeEach(() => {
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
vi.stubEnv('DSH_PRODUCT_ANALYTICS_ENABLED', undefined)
harness.analyticsEnabled = true
})
afterEach(async () => {
@@ -2167,7 +2175,7 @@ it.each([['light', false], ['dark', true]] as const)('opens Platform authorizati
})
it('disables native product events for a disabled Desktop launch', async () => {
vi.stubEnv('DSH_PRODUCT_ANALYTICS_ENABLED', '0')
harness.analyticsEnabled = false
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
@@ -2175,5 +2183,5 @@ it('disables native product events for a disabled Desktop launch', async () => {
harness.hosts[0]!.ready.resolve()
await harness.navigated.promise
expect(harness.analytics).not.toHaveBeenCalled()
expect(harness.hosts[0]!.environment?.DSH_PRODUCT_ANALYTICS_ENABLED).toBe('0')
harness.analyticsEnabled = true
})
+2
View File
@@ -20,6 +20,7 @@ function transport(preference?: string) {
if (method === 'account/getState') value = { links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }
else if (method === 'settings/describe') value = { namespaces }
else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }]
else if (method === 'productAnalytics/enabled') value = true
else if (method === 'productAnalytics/report') value = undefined
else if (method === 'credentials/set') keys.set(payload.args.ref, payload.args.value)
else value = Object.fromEntries(payload.args.refs.map(ref => [ref, { configured: keys.has(ref), writable: true }]))
@@ -35,6 +36,7 @@ describe('desktop welcome Web operations', () => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
expect(await backend.analyticsEnabled()).toBe(true)
expect(await backend.save('sk-example')).toEqual({ ok: true })
expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null })
@@ -16,7 +16,8 @@ function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise<WelcomeNotic
const stopAccount = vi.fn()
const api = {
takeNotice,
analytics: vi.fn(async (_action: string) => {}),
analytics: vi.fn(async (_event: string, _attributes: object) => {}),
analyticsEnabled: async () => true,
onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount),
startSignIn: vi.fn(async (): Promise<AccountView> => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
cancelSignIn: vi.fn(async (): Promise<AccountView> => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
@@ -333,10 +334,10 @@ it('reports each return to the welcome entry once, including account cancellatio
expect(view.api.analytics).not.toHaveBeenCalled()
fireEvent.click(view.button('#api-key'))
fireEvent.click(view.button('#back-to-login'))
expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['api-key', 'view'])
expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['auth_page_click', 'auth_page_view'])
const pending = Promise.withResolvers<AccountView>()
view.api.startSignIn.mockReturnValueOnce(pending.promise)
fireEvent.click(view.button('#sign-in'))
await act(async () => { pending.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) })
expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['api-key', 'view', 'sign_in', 'view'])
expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['auth_page_click', 'auth_page_view', 'auth_page_click', 'auth_page_view'])
})
@@ -119,6 +119,7 @@ vi.mock('../src/host-process.ts', () => ({
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
analyticsEnabled: async () => false,
readLocalePreference: async () => state.preference,
read: async () => {
await state.beforeRead()
+24 -2
View File
@@ -6,13 +6,13 @@ import { needsWelcome, WELCOME_IPC } from '../src/welcome-api.ts'
const electron = vi.hoisted(() => ({
create: vi.fn<(options: unknown) => ReturnType<typeof createWindow>>(),
root: '/desktop-app',
handlers: new Map<string, (event: unknown, value?: unknown) => Promise<unknown>>(),
handlers: new Map<string, (event: unknown, value?: unknown, attributes?: unknown) => Promise<unknown>>(),
}))
vi.mock('electron', () => ({
app: { getAppPath: () => electron.root },
BrowserWindow: vi.fn(function (options: unknown) { return electron.create(options) }),
ipcMain: {
handle: (name: string, handler: (event: unknown, value?: unknown) => Promise<unknown>) => {
handle: (name: string, handler: (event: unknown, value?: unknown, attributes?: unknown) => Promise<unknown>) => {
if (electron.handlers.has(name)) throw new Error(`duplicate IPC handler: ${name}`)
electron.handlers.set(name, handler)
},
@@ -40,6 +40,7 @@ function createWindow() {
beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() })
const operations = {
analyticsEnabled: async () => true,
takeNotice: async () => undefined,
startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
@@ -186,3 +187,24 @@ describe('desktop welcome window', () => {
expect(needsWelcome({ loggedIn: true, hasApiKey: true })).toBe(false)
})
})
it('accepts only permitted event fields and reads the current policy over IPC', async () => {
const window = createWindow()
electron.create.mockReturnValue(window)
const analytics = vi.fn(async () => {})
let enabled = true
await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, analytics, analyticsEnabled: async () => enabled })
const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
const policy = electron.handlers.get(WELCOME_IPC.analyticsEnabled)!
expect(await policy(own)).toBe(true)
enabled = false
expect(await policy(own)).toBe(false)
const report = electron.handlers.get(WELCOME_IPC.analytics)!
await report(own, 'auth_page_click', { button_name: 'api-key' })
expect(analytics).toHaveBeenLastCalledWith('auth_page_click', { button_name: 'api-key' })
await expect(report(own, 'auth_page_click', { button_name: 'api-key', token: 'private' })).rejects.toThrow('invalid analytics')
await expect(report(own, 'desktop_app_launch', {})).rejects.toThrow('invalid analytics')
await expect(report(own, 'auth_page_view', null)).rejects.toThrow('invalid analytics')
await report(own, 'api_key_save_click', {})
expect(analytics).toHaveBeenLastCalledWith('api_key_save_click', {})
})
+3
View File
@@ -15,6 +15,9 @@
},
{
"path": "../../packages/credentials/deepseek-account"
},
{
"path": "../../packages/client/product-analytics/tsconfig.client.json"
}
]
}
+5 -4
View File
@@ -498,14 +498,15 @@ export interface Config {
## `@deepseek-ai/dsh-client-product-analytics`
- `inject`: `deepseekAccount` · `webServer`
- `source`: [`packages/client/product-analytics/src/index.ts:13`](../packages/client/product-analytics/src/index.ts)
- `inject`: `deepseekAccount` · `productTelemetry`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts)
```ts config-catalog
/** Application-owned collection policy; no user settings surface. */
export interface Config {
/** Desktop launcher opt-in; ordinary Web defaults to disabled. */
enabled: boolean
/** Live application collection policy; ordinary Web does not mount this service. */
enabled: Volatile<boolean>
/** Running Desktop release, absent when unavailable. */
appVersion?: string
}
+5 -4
View File
@@ -500,14 +500,15 @@ export interface Config {
## `@deepseek-ai/dsh-client-product-analytics`
- `inject`: `deepseekAccount` · `webServer`
- `source`: [`packages/client/product-analytics/src/index.ts:13`](../packages/client/product-analytics/src/index.ts)
- `inject`: `deepseekAccount` · `productTelemetry`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts)
```ts config-catalog
/** Application-owned collection policy; no user settings surface. */
export interface Config {
/** Desktop launcher opt-in; ordinary Web defaults to disabled. */
enabled: boolean
/** Live application collection policy; ordinary Web does not mount this service. */
enabled: Volatile<boolean>
/** Running Desktop release, absent when unavailable. */
appVersion?: string
}
+3 -3
View File
@@ -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:53`](../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:52`](../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:87`](../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` |
@@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `product-analytics`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -102,7 +102,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` |
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
<!-- END GENERATED event-producer-consumer:undeclared -->
+3 -3
View File
@@ -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:53`](../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:52`](../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:87`](../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` |
@@ -82,7 +82,7 @@
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `product-analytics`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -104,7 +104,7 @@
| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` |
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
<!-- END GENERATED event-producer-consumer:undeclared -->
+8 -1
View File
@@ -45,7 +45,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.productAnalytics` — `ProductAnalytics`
Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.
Authenticated event intake; disabled instances do not inspect identity or accept new events.
```ts cordis-catalog
/**
@@ -54,6 +54,13 @@ Authenticated event intake; disabled instances neither inspect identity nor cons
*/
@Remote enabled(): boolean
/**
* Stream the effective policy initially and after live configuration edits.
* @param signal - subscriber lifetime.
* @returns current policy values until cancellation or service disposal.
*/
@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable<boolean>
/**
* Submit selected Desktop fields; missing identity is omitted and never generated.
* @param event - typed product event without message contents or credentials.
+8 -1
View File
@@ -45,7 +45,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.productAnalytics` — `ProductAnalytics`
Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.
Authenticated event intake; disabled instances do not inspect identity or accept new events.
```ts cordis-catalog
/**
@@ -54,6 +54,13 @@ Authenticated event intake; disabled instances neither inspect identity nor cons
*/
@Remote enabled(): boolean
/**
* Stream the effective policy initially and after live configuration edits.
* @param signal - subscriber lifetime.
* @returns current policy values until cancellation or service disposal.
*/
@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable<boolean>
/**
* Submit selected Desktop fields; missing identity is omitted and never generated.
* @param event - typed product event without message contents or credentials.
+2 -2
View File
@@ -6,8 +6,8 @@
en: 1c32778606f3027a
zh: db7994fdb50333c8
/deepseek-ai-dsh-api-remotes:
en: 526b2d71ed1fe09c
zh: 1d68ca3d59f0ee6b
en: f0e152bd9c846ec4
zh: dba8ab5fa1181276
/deepseek-ai-dsh-api-remotes/summary:
en: 3c54c157bb823fd0
zh: 0db6778e94ae671c
+1 -1
View File
@@ -7,7 +7,7 @@ kind: "package-reference"
English | [中文](README.zh.md)
Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its launch-time switch. Web usage is excluded.
Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
## Summary
+1 -1
View File
@@ -7,7 +7,7 @@ kind: "package-reference"
[English](README.md) | 中文
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
## 概述
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-app-boot/use-this-package:
en: be4004cb1e04f7b4
zh: eace2e738f6b6f97
en: f6fa378225009c8f
zh: 324c0e4640bb1358
/deepseek-ai-dsh-app-boot/use-this-package/when-to-use-it:
en: 2fdfe02f2ed4a258
zh: d748228ece579c4a
-4
View File
@@ -11,7 +11,6 @@ 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)
@@ -26,8 +25,6 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## 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
@@ -197,7 +194,6 @@ Boot itself changes no request prefix. `addHarnessSourceSection` places its sour
<a id="known-limitations-and-deferred-work"></a>
These limits describe when this boot library is a poor fit or needs special care. They are current package constraints, not a task backlog.
- **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution.
-4
View File
@@ -11,7 +11,6 @@ kind: "package-library"
`dsh-app-boot` 是 `dsh` profile(包括 Python 运行时 wheel 包所含的 CLI(命令行界面))背后的共享 Loader 启动库。它加载环境层、组合 profile 组合包与 patch、启动每个插件,再返回运行中的应用,或指出失败插件与原因。产品应用使用 `dsh` launcher 而不发布单独 bin;直接配置 helper 只保留给低层嵌入方与测试。你还可以在启动前预览生效配置,按 profile 选择实时或仅启动时应用 patch,并让持有终端的应用在致命退出前恢复终端。
## 目录
- [使用本包](#use-this-package)
@@ -26,8 +25,6 @@ kind: "package-library"
<a id="use-this-package"></a>
## 使用本包
`resolveLaunchFlag(name, value, defaultValue)` 仅接受 `0`、`1` 或缺失值;其他值会抛出包含变量名的错误。
用此包启动应用是一个小而显式的入口:你给它一个配置文件,它运行整个启动过程。本节说明你能做什么、能得到什么;每个结果背后的 helper 调用记录在下方可折叠的实现章节中。
### 何时使用
@@ -197,7 +194,6 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
<a id="known-limitations-and-deferred-work"></a>
这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。
- **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。
-1
View File
@@ -18,7 +18,6 @@ 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 {
-15
View File
@@ -1,15 +0,0 @@
/** 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`)
}
@@ -1,11 +0,0 @@
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')
})
+2 -2
View File
@@ -6,8 +6,8 @@
en: af2e4e5296596e8f
zh: 70c0b5ac93209d00
/deepseek-ai-dsh-web-app:
en: 86eb3c6e467aa9a5
zh: 8fad365e5909bb3f
en: 8523b283e794cf59
zh: 7764704f7e98f19b
/deepseek-ai-dsh-web-app/summary:
en: 0ebb6c3ca8c0a84e
zh: 4d85e02c769ddae0
+1 -1
View File
@@ -7,7 +7,7 @@ kind: "package-bundle"
English | [中文](README.zh.md)
Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its launch-time switch. Web usage is excluded.
Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
## Summary
+1 -1
View File
@@ -7,7 +7,7 @@ kind: "package-bundle"
[English](README.md) | 中文
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
## 概述
+3 -2
View File
@@ -44,7 +44,7 @@
- insert:
- id: desktop-product-telemetry
name: '@deepseek-ai/dsh-host-product-telemetry-otel'
disabled: !!js "ctx.get('profileContext')?.name !== 'desktop' || process.env.DSH_DESKTOP_PRODUCT_ANALYTICS !== '1'"
disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
config:
timeoutMillis: 1000
exportTimeoutMillis: 1500
@@ -55,8 +55,9 @@
- id: product-analytics
name: '@deepseek-ai/dsh-client-product-analytics'
disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
config:
enabled: !!js "ctx.get('profileContext')?.name === 'desktop' && process.env.DSH_DESKTOP_PRODUCT_ANALYTICS === '1'"
enabled: true
appVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION
# Host-owned opt-in sampled when a new Web session receives its preset
@@ -6,6 +6,7 @@ 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 Analytics from '@deepseek-ai/dsh-client-product-analytics'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
afterEach(() => { vi.unstubAllEnvs() })
@@ -18,7 +19,6 @@ it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop l
onTestFinished(async () => { const closed = once(server, 'close'); server.close(); server.closeAllConnections(); await closed })
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('missing collector address')
vi.stubEnv('DSH_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()
@@ -27,20 +27,47 @@ it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop l
name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json',
cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
})
const identity = vi.fn().mockResolvedValue(undefined)
ctx.provide('deepseekAccount', { getDeviceIdentity: identity } as never)
ctx.baseUrl = 'file:///'
await ctx.plugin(Loader).await()
ctx.loader.builtins.telemetry = Telemetry
ctx.loader.builtins.analytics = Analytics
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' })))
.flatMap(patch => patch.insert ?? []).filter(row => row.id === 'desktop-product-telemetry' || row.id === 'product-analytics')
const entries = rows.map(row => ({ ...row, name: row.id === 'desktop-product-telemetry' ? 'cordis:telemetry' : 'cordis:analytics' }))
await ctx.loader.root.update(entries)
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()
expect(ctx.get('productAnalytics')).toBeUndefined()
return
}
expect(rows[0]!.config).toMatchObject({ timeoutMillis: 1000, exportTimeoutMillis: 1500, shutdownTimeoutMillis: 2000 })
const analytics = ctx.productAnalytics
const analyticsFiber = ctx.loader.resolve('product-analytics').fiber
const telemetryFiber = entry.fiber
const lifetime = new AbortController()
onTestFinished(() => { lifetime.abort() })
const policy = analytics.watchPolicy(lifetime.signal)[Symbol.asyncIterator]()
expect(await policy.next()).toEqual({ value: true, done: false })
for (const enabled of [false, true]) {
const changed = policy.next()
await ctx.loader.root.update(entries.map(row => row.id === 'product-analytics'
? { ...row, config: { ...row.config as Record<string, unknown>, enabled } } : row))
await ctx.loader.await()
expect(await changed).toEqual({ value: enabled, done: false })
expect(ctx.loader.resolve('product-analytics').fiber === analyticsFiber).toBe(true)
expect(entry.fiber === telemetryFiber).toBe(true)
if (!enabled) {
await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
expect(identity).not.toHaveBeenCalled()
}
}
lifetime.abort()
await policy.return?.()
ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() })
const disposal = entry.fiber!.dispose()
await received.promise
@@ -15,11 +15,11 @@
en: 2fee9973f19f9401
zh: 8ab4b55413cb8408
/deepseek-ai-dsh-client-product-analytics/use-this-package:
en: 704784e88b546490
zh: a156401e08250c2b
en: c136c4af1b72bdcb
zh: 8e287dfed42cea82
/deepseek-ai-dsh-client-product-analytics/understand-the-implementation:
en: a02729768db615ed
zh: ed65aa11e5dab306
en: 5fcaa1cb77e47875
zh: 434925be40f7d6ed
/deepseek-ai-dsh-client-product-analytics/model-experience:
en: 291751b16b8a37e7
zh: 3b38008f4364ec41
@@ -27,8 +27,8 @@
en: 1f5125831808e7a9
zh: 97566fdde332df39
/deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work:
en: a77e22cd31e26e75
zh: 739f0ec9dde78f49
en: a33ad31745fd5240
zh: 8b579cfc4c9b8155
/deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work/dev-note:
en: 0a1253c6f7b5e8b9
zh: 9f56528562c7c914
+6 -6
View File
@@ -21,15 +21,15 @@ Collect selected Desktop interactions through the existing OTel product exporter
<a id="use-this-package"></a>
## Use this package
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.
Desktop mounts Analytics and its required Telemetry exporter. The `product-analytics` settings namespace owns the live `enabled` field, which defaults to `true`; configure it through the existing Cordis Config / settings mechanism. There is no user-facing control. Ordinary Web mounts neither service. Disabled collection reads no analytics identity and accepts no new events; the exporter remains mounted and may finish exporting records already queued. 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.
The renderer and Electron subscribe to the Host policy through the existing authenticated stream, including reconnection. Electron also reads the initial policy before native launch reporting. Welcome obtains the current policy over IPC. The Host checks its current volatile configuration again at event intake and after identity lookup. `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 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 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.
Electron supplies internal `DSH_PRODUCT_ANALYTICS_APP_VERSION` from its running version; the exporter requires it. This variable belongs to the Desktop launcher-to-Host protocol. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget.
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.
The [event types](src/events.ts) own names and allowed fields. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Message submission retains its original occurrence timestamp and captures `submit_source`, `submit_type`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Programmatic submissions omit `submit_source`; keyboard and button gestures use `enter` and `click`. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing.
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`.
@@ -39,7 +39,7 @@ Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation re
<details>
<summary>Implementation internals — click to expand</summary>
Presentational components receive callbacks. Shared Client services resolve the optional Desktop analytics sender, which requires both the native preload marker and the Host's collection flag. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host enriches identity immediately before queueing. It observes live compaction events without replaying Session history. No runtime invariant companion is published because collection has no independent delivery acknowledgement to compare.
Presentational components receive callbacks. Shared Client services resolve the optional Desktop analytics sender, which requires both the native preload marker and the synchronized Host policy. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host enriches identity immediately before queueing. It observes live compaction events without replaying Session history. No runtime invariant companion is published because collection has no independent delivery acknowledgement to compare.
</details>
@@ -58,7 +58,7 @@ None; collection does not modify model requests.
Delivery is best effort.
- The switch is sampled at launch; there is no user control or live preference update.
- Disabling stops new collection; it does not clear the exporter queue.
- Renderer failures are discarded, and the exporter has no durable outbox or warehouse acknowledgement.
- Native startup reporting waits for Host authentication; a process that fails before Host readiness cannot report its launch.
@@ -21,15 +21,15 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
桌面启动器默认开启产品埋点。在启动环境中设置 `DSH_PRODUCT_ANALYTICS_ENABLED=0`,下次启动时关闭;`1` 表示开启。其他值会被拒绝。该设置统一控制原生登录与升级事件、渲染进程事件和 Host 压缩事件采集。关闭后不会创建导出器、读取埋点身份或保留事件供以后补发。会话反馈遥测采用独立策略。
桌面端同时装配 Analytics 与必需的 Telemetry 导出器。`product-analytics` settings 命名空间持有动态 `enabled` 字段,默认 `true`,通过现有 Cordis Config / settings 机制配置,暂不提供用户操作入口。普通 Web 不装配这两个服务。关闭后不读取埋点身份、不接收新事件;导出器仍保持挂载,已入队事件可以继续导出。会话反馈遥测采用独立策略。
共享组合向本插件提供 `enabled`(默认 `false`)和可选的 `appVersion`。桌面 Host 会开启它,普通 Web Host 保持关闭。`DSH_PRODUCT_ANALYTICS_OTLP_URL` 可覆盖导出目的地,用于隔离的接收端。[导出器](../../host/product-telemetry-otel/README.zh.md)负责批量发送、重试和退出时的交付。
渲染端和 Electron 通过现有认证流订阅 Host 策略变化及重连后的值;Electron 在原生启动上报前还会读取初始策略。欢迎窗口通过 IPC 获取当前策略。Host 在接收事件及读取身份后都检查当前 volatile 配置。`DSH_PRODUCT_ANALYTICS_OTLP_URL` 可覆盖导出目的地,用于隔离的接收端。[导出器](../../host/product-telemetry-otel/README.zh.md)负责批量发送、重试和退出时的交付。
公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 通过 `deepseekAccount.getDeviceIdentity()` 读取不含凭据的设备、账户和操作系统字段。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。
桌面 Host 从经过校验的启动开关派生内部变量 `DSH_DESKTOP_PRODUCT_ANALYTICS=0|1`。Electron 通过内部变量 `DSH_PRODUCT_ANALYTICS_APP_VERSION` 传递运行版本;启用导出器时该值必填。这些变量属于桌面启动器与 Host 之间的协议,不是 Web 开启采集的控件。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。启用采集但未挂载导出器时会记录配置警告。
Electron 通过内部变量 `DSH_PRODUCT_ANALYTICS_APP_VERSION` 传递运行版本;导出器要求该值存在。该变量属于桌面启动器与 Host 之间的协议。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。
[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅默认消息发送路径上报,已处理的命令不计入。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。
[事件类型](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`。
@@ -39,7 +39,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 Host 采集标记。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。本包不发布运行时不变量伴随模块,因为采集没有可用于比较的独立交付确认。
展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。本包不发布运行时不变量伴随模块,因为采集没有可用于比较的独立交付确认。
</details>
@@ -58,7 +58,7 @@ kind: "package-reference"
交付采用尽力而为的方式。
- 开关在启动时读取;没有用户控件或实时偏好更新。
- 关闭只停止新采集,不清空导出器队列。
- 渲染进程的发送失败会被丢弃,导出器没有持久化待发队列或数据仓库确认。
- 原生启动事件等待 Host 认证完成;若进程在 Host 就绪前失败,则无法上报启动事件。
@@ -60,7 +60,9 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*",
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
"@deepseek-ai/dsh-host-webserver": "workspace:*"
"@deepseek-ai/dsh-host-webserver": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/cordis-plugin-loader": "workspace:~"
},
"files": [
"lib/index.js",
@@ -73,7 +75,6 @@
],
"dependencies": {
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-credentials": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "^4.4.3"
}
@@ -1,7 +1,7 @@
/** Desktop renderer analytics sender; browser applications have no collection capability. */
import { Service, type Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
export type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
import type { ProductEvent, ProductEventMap } from '../events.ts'
export type { ProductEvent, ProductEventMap, TrackProductEvent } from '../events.ts'
@@ -12,21 +12,36 @@ declare module '@deepseek-ai/cordis' {
}
class DesktopAnalytics extends Service {
constructor(ctx: Context) { super(ctx, 'productAnalytics') }
private collecting = false
/** Whether this renderer may collect events under the launch-time policy. */
constructor(ctx: Context) {
super(ctx, 'productAnalytics')
if (!('dshDesktop' in globalThis)) return
const stream = ctx.remote.$stream<boolean>({
name: 'product analytics policy', open: signal => ctx.remote.productAnalytics.watchPolicy(signal),
ended: () => new Error('product analytics policy stream ended'),
carrierFailed: () => { this.collecting = false },
})
ctx.effect(() => () => { this.collecting = false; return stream.dispose() })
void (async () => {
for await (const frame of stream) { this.collecting = frame.value; frame.accept() }
})().catch(() => { this.collecting = false })
}
/** Whether the synchronized Host configuration currently permits collection. */
get enabled(): boolean {
return 'dshDesktop' in globalThis && (globalThis as typeof globalThis & { __DSH_PRODUCT_ANALYTICS__?: boolean }).__DSH_PRODUCT_ANALYTICS__ === true
return this.collecting
}
/**
* Send an event without retaining it for reconnect or later enablement.
* @param name - event name.
* @param attributes - approved business fields.
* @param timestamp - occurrence time; defaults to the current time.
*/
track<K extends keyof ProductEventMap>(name: K, attributes: ProductEventMap[K]): void {
track<K extends keyof ProductEventMap>(name: K, attributes: ProductEventMap[K], timestamp = Date.now()): void {
if (!this.enabled) return
const event = { eventName: name, attributes, timestamp: Date.now() } as ProductEvent
const event = { eventName: name, attributes, timestamp } as ProductEvent
void this.submit(event)
}
@@ -37,7 +52,7 @@ class DesktopAnalytics extends Service {
}
}
/** Analytics requires only the authenticated remote namespace. */
/** Analytics consumes authenticated RPC and its reconnecting policy stream. */
export const inject = ['remote', 'remote.productAnalytics']
/** @param ctx - browser application context. */
+39 -18
View File
@@ -1,18 +1,19 @@
/** Desktop-only analytics RPC and live compaction collection. */
import { type Context } from '@deepseek-ai/cordis'
import { type Context, type Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-host-product-telemetry-otel'
import type {} from '@deepseek-ai/dsh-deepseek-account'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-compaction/types'
import type { ProductEvent } from './events.ts'
/** Application-owned collection policy; no user settings surface. */
export interface Config {
/** Desktop launcher opt-in; ordinary Web defaults to disabled. */
enabled: boolean
/** Live application collection policy; ordinary Web does not mount this service. */
enabled: Volatile<boolean>
/** Running Desktop release, absent when unavailable. */
appVersion?: string
}
@@ -23,21 +24,18 @@ declare module '@deepseek-ai/cordis' {
}
}
/** Authenticated event intake; disabled instances neither inspect identity nor construct an exporter. */
/** Authenticated event intake; disabled instances do not inspect identity or accept new events. */
export default class ProductAnalytics extends TypertRemoteService {
static inject = ['deepseekAccount', 'webServer']
static Config = z.object({ enabled: z.boolean().default(false), appVersion: z.string() })
private readonly collection: { active: boolean }
static inject = ['deepseekAccount', 'productTelemetry']
static Config = z.object({ enabled: z.boolean().default(true).volatile(), appVersion: z.string() })
private active = true
private readonly listeners = new Set<() => void>()
constructor(ctx: Context, private readonly config: Config) {
super(ctx, 'productAnalytics')
this.collection = { active: config.enabled }
ctx.effect(() => () => { this.collection.active = false })
ctx.on('webserver/index-inject', (table) => {
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.effect(() => () => { this.active = false; for (const listener of this.listeners) listener() })
ctx.on('loader/volatile-update', () => { for (const listener of this.listeners) listener() })
ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })
ctx.on('session/event', (session, event) => {
if (event.type !== 'compaction/start') return
void this.report({ eventName: 'context_compression', timestamp: Date.now(),
@@ -50,7 +48,30 @@ export default class ProductAnalytics extends TypertRemoteService {
* @returns whether this Host currently accepts Desktop analytics.
*/
@Remote
enabled(): boolean { return this.collection.active }
enabled(): boolean { return this.active && this.config.enabled.get() }
/**
* Stream the effective policy initially and after live configuration edits.
* @param signal - subscriber lifetime.
* @returns current policy values until cancellation or service disposal.
*/
@Remote({ mode: 'stream' })
async *watchPolicy(signal: AbortSignal): AsyncIterable<boolean> {
let dirty = true
let wake: (() => void) | undefined
const changed = (): void => { dirty = true; wake?.() }
this.listeners.add(changed)
signal.addEventListener('abort', changed, { once: true })
try {
while (this.active && !signal.aborted) {
if (dirty) { dirty = false; yield this.enabled(); continue }
await new Promise<void>((resolve) => { wake = resolve })
}
} finally {
this.listeners.delete(changed)
signal.removeEventListener('abort', changed)
}
}
/**
* Submit selected Desktop fields; missing identity is omitted and never generated.
@@ -59,11 +80,11 @@ export default class ProductAnalytics extends TypertRemoteService {
*/
@Remote
async report(event: ProductEvent): Promise<void> {
if (!this.collection.active) return
if (!this.enabled()) return
try {
const identity = await this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined)
if (!this.enabled()) return
this.ctx.get('productTelemetry')?.emit({
this.ctx.productTelemetry.emit({
...event, body: event.eventName,
attributes: {
...event.attributes,
@@ -1,5 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, expect, it, vi } from 'vitest'
import type { RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
import * as Analytics from '../src/client/index.ts'
const cleanup: (() => Promise<void>)[] = []
@@ -7,17 +8,38 @@ afterEach(async () => {
for (const dispose of cleanup.splice(0).reverse()) await dispose()
vi.unstubAllGlobals()
})
async function setup() {
async function setup(enabled = true) {
const ctx = new Context()
cleanup.push(() => ctx.fiber.dispose())
const report = vi.fn().mockResolvedValue({ ok: true, value: undefined })
ctx.provide('remote', { productAnalytics: { report } } as never)
const values = [enabled]
let wake: (() => void) | undefined
let disposed = false
let failure: Error | undefined
let options: RemoteStreamOptions<boolean> | undefined
const stream = {
async *[Symbol.asyncIterator]() {
while (!disposed) {
if (failure !== undefined) throw failure
const value = values.shift()
if (value !== undefined) { yield { value, accept: () => {} }; continue }
await new Promise<void>((resolve) => { wake = resolve })
}
},
dispose: async () => { disposed = true; wake?.() },
}
ctx.provide('remote', { productAnalytics: { report, watchPolicy: vi.fn() }, $stream: (value: RemoteStreamOptions<boolean>) => { options = value; return stream } } as never)
ctx.provide('remote.productAnalytics', {} as never)
await ctx.plugin(Analytics)
return { ctx, report }
if ('dshDesktop' in globalThis) await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(enabled) })
return { ctx, report, options: () => options!,
async fail() { failure = new Error('terminal'); wake?.(); await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(false) }) },
async policy(enabled: boolean) {
values.push(enabled); wake?.()
await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(enabled) })
} }
}
it('never collects in Web even when the Host advertises analytics', async () => {
vi.stubGlobal('__DSH_PRODUCT_ANALYTICS__', true)
const b = await setup()
expect(b.ctx.productAnalytics.enabled).toBe(false)
b.ctx.productAnalytics.track('desktop_app_launch', {})
@@ -25,17 +47,15 @@ it('never collects in Web even when the Host advertises analytics', async () =>
})
it('drops disabled events and has no backfill when enabled', async () => {
vi.stubGlobal('dshDesktop', {})
vi.stubGlobal('__DSH_PRODUCT_ANALYTICS__', false)
const b = await setup()
const b = await setup(false)
b.ctx.productAnalytics.track('auth_page_view', {})
expect(b.report).not.toHaveBeenCalled()
vi.stubGlobal('__DSH_PRODUCT_ANALYTICS__', true)
await b.policy(true)
b.ctx.productAnalytics.track('auth_page_click', { button_name: 'sign_in' })
expect(b.report).toHaveBeenCalledExactlyOnceWith({ eventName: 'auth_page_click', timestamp: expect.any(Number) as number, attributes: { button_name: 'sign_in' } })
})
it('does not retry or propagate transport rejection', async () => {
vi.stubGlobal('dshDesktop', {})
vi.stubGlobal('__DSH_PRODUCT_ANALYTICS__', true)
const b = await setup()
b.report.mockRejectedValueOnce(new Error('offline'))
b.ctx.productAnalytics.track('auth_page_view', {})
@@ -45,10 +65,30 @@ it('does not retry or propagate transport rejection', async () => {
it('does not interrupt an action when Remote access fails synchronously', async () => {
vi.stubGlobal('dshDesktop', {})
vi.stubGlobal('__DSH_PRODUCT_ANALYTICS__', true)
const b = await setup()
b.report.mockImplementationOnce(() => { throw new Error('Remote namespace detached') })
expect(() => { b.ctx.productAnalytics.track('auth_page_view', {}) }).not.toThrow()
await Promise.resolve()
expect(b.report).toHaveBeenCalledOnce()
})
it('keeps the supplied occurrence time and stops new events on policy changes', async () => {
vi.stubGlobal('dshDesktop', {})
const b = await setup()
b.ctx.productAnalytics.track('auth_page_view', {}, 123)
expect(b.report).toHaveBeenCalledWith({ eventName: 'auth_page_view', attributes: {}, timestamp: 123 })
await b.policy(false)
b.ctx.productAnalytics.track('auth_page_view', {})
expect(b.report).toHaveBeenCalledOnce()
})
it('fails closed on stream loss and accepts a fresh policy after reconnection', async () => {
vi.stubGlobal('dshDesktop', {})
const b = await setup()
b.options().open(new AbortController().signal)
expect(b.options().ended(true).message).toContain('policy stream ended')
b.options().carrierFailed?.(new Error('offline'))
expect(b.ctx.productAnalytics.enabled).toBe(false)
await b.policy(true)
await b.fail()
})
@@ -5,7 +5,6 @@ import { afterEach, expect, it, vi } from 'vitest'
import ProductTelemetry, { Config as TelemetryConfig, type ProductTelemetryRecord } from '@deepseek-ai/dsh-host-product-telemetry-otel'
import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
import { CompactionId } from '@deepseek-ai/dsh-compaction'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import Analytics from '../src/index.ts'
const cleanup: (() => Promise<void>)[] = []
@@ -100,13 +99,6 @@ it.each([true, false])('collects live manual and automatic compaction only when
expect(b.getDeviceIdentity).toHaveBeenCalledTimes(enabled ? 2 : 0)
})
it.each([true, false])('publishes the launch-time collection flag to renderers: %s', async (enabled) => {
const b = await setup(enabled)
const injections: IndexInjection[] = []
b.ctx.emit('webserver/index-inject', injections)
expect(injections).toEqual([{ kind: 'global', name: '__DSH_PRODUCT_ANALYTICS__', value: enabled }])
})
it('omits an unavailable account and isolates exporter submission failure', async () => {
const b = await setup(true)
b.getDeviceIdentity.mockRejectedValueOnce(new Error('account unavailable'))
@@ -117,14 +109,51 @@ it('omits an unavailable account and isolates exporter submission failure', asyn
})
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)
}
it('requires the exporter even when collection is disabled', async () => {
const ctx = new Context()
cleanup.push(() => ctx.fiber.dispose())
ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never)
const fiber = ctx.plugin(Analytics, { enabled: false })
await Promise.resolve()
expect(ctx.get('productAnalytics')).toBeUndefined()
ctx.provide('productTelemetry', { emit: vi.fn() } as never)
await fiber
expect(ctx.productAnalytics.enabled()).toBe(false)
})
it('streams live policy changes and rejects intake disabled during identity lookup', async () => {
const b = await setup(true)
const lifetime = new AbortController()
const iterator = b.ctx.productAnalytics.watchPolicy(lifetime.signal)[Symbol.asyncIterator]()
expect(await iterator.next()).toEqual({ value: true, done: false })
const identity = Promise.withResolvers<undefined>()
b.getDeviceIdentity.mockReturnValueOnce(identity.promise)
const report = b.ctx.productAnalytics.report({ eventName: 'auth_page_view', timestamp: 1, attributes: {} })
const next = iterator.next()
// Loader commits the stable Config reference before publishing this notification.
const { updateVolatile, createVolatile } = await import('../../../../vendor/cosmokit/src/volatile.ts')
const config = b.fiber.config as import('../src/index.ts').Config
updateVolatile(config.enabled, createVolatile(false))
b.fiber.ctx.emit('loader/volatile-update', [['enabled']])
expect(await next).toEqual({ value: false, done: false })
identity.resolve(undefined)
await report
expect(b.emit).not.toHaveBeenCalled()
const closed = iterator.next()
lifetime.abort()
expect(await closed).toEqual({ value: undefined, done: true })
})
it('wakes pending policy readers on disposal and registers hidden settings presentation', async () => {
const b = await setup(true)
const dispose = vi.fn()
const configure = vi.fn(() => dispose)
b.ctx.provide('settings', { configure } as never)
await vi.waitFor(() => { expect(configure).toHaveBeenCalledWith({ auto: false }, b.fiber) })
const iterator = b.ctx.productAnalytics.watchPolicy(new AbortController().signal)[Symbol.asyncIterator]()
await iterator.next()
const pending = iterator.next()
await b.fiber.dispose()
expect(await pending).toEqual({ done: true, value: undefined })
expect(dispose).toHaveBeenCalledOnce()
})
@@ -39,6 +39,12 @@
},
{
"path": "../../llm/llm"
},
{
"path": "../../settings/settings"
},
{
"path": "../../../vendor/loader"
}
]
}
+3 -1
View File
@@ -245,7 +245,9 @@ export function apply(ctx: Context): void {
},
read: () => chatScrollPositions.get(sessionId) ?? null,
},
forkAt: (seq, messageId) => {
forkAt: (seq) => {
const turn = [...chat.getSnapshot().timeline.turns.values()].find(turn => turn.end?.seq === seq)
const messageId = turn?.data.get('turn-tail')?.closing?.finalNode.messageId
ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true, onCreated: (childId) => {
ctx.get('productAnalytics')?.track('branch_session_click', { session_id: childId, parent_session_id: sessionId, ...messageId === undefined ? {} : { parent_message_id: messageId }, click_position: 'footer' })
} })
@@ -66,7 +66,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
clock="end"
// The branch action owns boundary resolution: it sends the real
// turn/end seq it already has, and the Host cuts exactly there.
onBranch={() => { forkAt(data.seq, messageId) }}
onBranch={() => { forkAt(data.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
@@ -165,7 +165,7 @@ export interface ChatNodeOwnerProps {
openSkill: (name: string) => void
openFile: (path: string, options?: OpenFileOptions) => void
inspectCall: ((callId: ToolCallId) => void) | undefined
forkAt: (seq: number, messageId?: MessageId) => void
forkAt: (seq: number) => void
/**
* Session-authorized image loader, down-threaded from the Chat view so a
* chat-node renderer can render the attachment presentation slot directly
@@ -255,7 +255,7 @@ export interface ChatViewInjected {
save: (position: ChatScrollPosition | null) => void
read: () => ChatScrollPosition | null
}
forkAt: (seq: number, messageId?: MessageId) => void
forkAt: (seq: number) => void
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
@@ -1,4 +1,4 @@
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import { 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'
@@ -176,8 +176,16 @@ describe('Chat inject API', () => {
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' }))
await b.runtime.sessions.replaceEvents(ROOT, [
{ type: 'event', event: { type: 'turn/start', seq: SessionSeq(10), time: 10, data: { turn: 1 } } },
{ type: 'event', event: { type: 'step/start', seq: SessionSeq(11), time: 11, data: { turn: 1, step: 1 } } },
{ type: 'event', event: { type: 'assistant/message', seq: SessionSeq(12), time: 12, surfaceOp: 'append', data: {
turn: 1, step: 1, stream: [], message: { id: MessageId('reply'), role: 'assistant', content: [{ type: 'text', text: 'answer' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } },
} } },
{ type: 'event', event: { type: 'turn/end', seq: SessionSeq(17), time: 17, data: { turn: 1, reason: { kind: 'completed' } } } },
])
injected.forkAt(17)
expect(track).toHaveBeenLastCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, parent_message_id: 'reply', click_position: 'footer' })
forkCreated.mockRestore()
injected.forkAt(17)
await vi.waitFor(() => {
@@ -1657,7 +1657,7 @@ describe('ChatView', () => {
fireEvent.click(branchButtons[0]!)
// The branch action sends the real turn/end seq (the exact inclusive
// Host boundary), not the assistant node seq.
expect(h.forkAt).toHaveBeenCalledWith(3, undefined)
expect(h.forkAt).toHaveBeenCalledWith(3)
}))
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
@@ -3070,7 +3070,7 @@ describe('ChatView', () => {
expect(buttons).toHaveLength(1)
expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(buttons[0]!)
expect(h.forkAt.mock.calls).toEqual([[3, undefined]])
expect(h.forkAt.mock.calls).toEqual([[3]])
})
it('disables fork when the indexed Turn has a later steering Node', () => {
@@ -18,8 +18,8 @@
en: 13ae68865fa315d7
zh: c152f020abf20f35
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: ea2f1e81b1f9a38a
zh: a2d4745b3e93e5a9
en: 7f1d7a29d2238f3a
zh: f7a66d2f34a1cd42
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
@@ -76,6 +76,8 @@ The resident composer survives no-Session and Session transitions. Whitespace hi
Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads.
Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending.
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
@@ -76,6 +76,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
@@ -9,3 +9,19 @@ export type InputSubmitMode = BusyEnterBehavior
/** Keyboard gesture whose delivery mode the submission policy resolves. */
export type ComposerSubmitGesture = 'enter' | 'accelerated'
/** Session facts captured when a message submission starts, before asynchronous command arbitration. */
export interface MessageSubmissionState {
readonly sessionId?: import('@deepseek-ai/dsh-session/types').SessionId
readonly model?: { readonly provider: string; readonly name: string; readonly effort?: string }
readonly runMode: 'default' | 'plan' | 'goal'
readonly running: boolean
}
/** Immutable occurrence and delivery intent carried by one composer attempt. */
export interface MessageSubmission {
readonly timestamp: number
readonly source?: 'click' | 'enter'
readonly mode: InputSubmitMode
readonly state?: MessageSubmissionState
}
@@ -11,7 +11,7 @@ import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts'
import type { InputSubmitMode } from './composer-submission.ts'
import type { InputSubmitMode, MessageSubmission } from './composer-submission.ts'
/** Attachment payload passed to a claimed command submission. */
export type SubmitAttachment =
@@ -283,6 +283,8 @@ export interface SubmitAttempt {
readonly draftSnapshot: string
/** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode
/** Original message occurrence, retained across command arbitration. */
readonly submission?: MessageSubmission
}
/**
@@ -296,7 +298,7 @@ export type InputEvent =
/** The editor applied a claim-token replacement: enter claimed. */
| { readonly type: 'claim'; readonly claim: CommandClaim }
/** Enter submission with the current clipboard projection. */
| { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string }
| { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string; readonly submission?: MessageSubmission }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
/** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */
@@ -22,7 +22,7 @@ import type {
import type {
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan,
} from '../contract/draft-editor.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { InputSubmitMode, MessageSubmission, MessageSubmissionState } from '../contract/composer-submission.ts'
import { SubmitMachine } from './machine.ts'
import { DraftEditorRuntime } from './editor/runtime.ts'
import type { EditorProjection } from './editor/projection.ts'
@@ -41,8 +41,10 @@ export interface PopupDismissFace {
export interface SessionInputDeps {
/** Session-scope ctx handed to claim.submit transactions. */
actx: Context
/** Capture mode and gesture before asynchronous submission or queue admission. */
captureSubmit?: (mode: InputSubmitMode, source: 'click' | 'enter' | undefined) => (() => void) | undefined
/** Snapshot the Session facts before asynchronous command arbitration. */
submissionState?: () => MessageSubmissionState
/** Notify one ordinary message attempt before reference serialization. */
messageSubmitted?: (submission: MessageSubmission) => void
/** 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,7 +134,6 @@ export class SessionInputShell implements SessionInput {
submit: () => { this.submit('queue') },
}
private readonly submittedAnalytics = new WeakMap<SubmitAttempt, () => void>()
private readonly core = new SubmitMachine()
private readonly draftEditor: DraftEditorRuntime
private get projection(): EditorProjection {
@@ -295,10 +296,14 @@ export class SessionInputShell implements SessionInput {
*/
submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void {
if (this.disposed) return
let report: (() => void) | undefined
const timestamp = Date.now()
let state: MessageSubmissionState | 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. */ }
try { state = this.deps.submissionState?.() } catch (_error) { /* Optional Session observations cannot interrupt submission. */ }
}
const submission: MessageSubmission = Object.freeze({
timestamp, mode, ...source === undefined ? {} : { source }, ...state === undefined ? {} : { state },
})
if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) {
if (this.snapshot.phase === 'plain') {
const attachmentIds = [...this.attachmentIds]
@@ -307,7 +312,7 @@ export class SessionInputShell implements SessionInput {
const flight = this.attachmentFlightSeq
this.attachmentFlights.set(flight, { controller, attachmentIds })
this.commitSend(attachmentIds)
this.reportSubmission(report)
this.notifySubmission(submission)
void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => {
if (this.disposed || !this.attachmentFlights.delete(flight)) return
if (outcome.kind === 'success') return
@@ -330,7 +335,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 }, report)
this.dispatchRun({ type: 'enter', mode, draft: this.projection.clipboardText, submission })
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
@@ -579,14 +584,9 @@ export class SessionInputShell implements SessionInput {
}
/** Dispatch + execute, refreshing the claim decoration when the styled token flips. */
private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0], report?: () => void): void {
private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
const beforeToken = this.activeClaimToken()
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()
}
@@ -648,8 +648,7 @@ export class SessionInputShell implements SessionInput {
draft: string,
mode: InputSubmitMode,
): void {
this.reportSubmission(this.submittedAnalytics.get(attempt))
this.submittedAnalytics.delete(attempt)
this.notifySubmission(attempt.submission)
const attachmentIds = [...this.attachmentIds]
this.attachmentIds = []
const occurrences = this.projection.occurrences
@@ -694,8 +693,9 @@ export class SessionInputShell implements SessionInput {
)
}
private reportSubmission(report: (() => void) | undefined): void {
try { report?.() } catch (_error) { /* Analytics cannot interrupt submission. */ }
private notifySubmission(submission: MessageSubmission | undefined): void {
if (submission === undefined) return
try { this.deps.messageSubmitted?.(submission) } catch (_error) { /* Notification consumers cannot interrupt submission. */ }
}
/** Settle one detached default send independently of other sends. */
@@ -27,6 +27,7 @@ import type { ComposerKeyboard } from '../contract/draft-editor.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
import { reportMessageSubmission } from './submission-analytics.ts'
/** Structural command face for per-session popup resolution. */
interface CommandFace {
@@ -94,23 +95,23 @@ export class InputHub implements SessionInputResolver {
const { session, ctx: actx } = binding
const shell = new SessionInputShell({
actx,
captureSubmit: (mode, source) => {
const analytics = this.rootCtx.get('productAnalytics')
if (!analytics?.enabled) return
submissionState: () => {
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 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',
...source === undefined ? {} : { submit_source: source }, submit_type: state.running ? mode : 'normal',
} as const
return () => { analytics.track('send_button_click', attributes) }
return Object.freeze({
...state.blank ? {} : { sessionId: state.sessionId },
...selection == null ? {} : { model: Object.freeze({
provider: selection.provider, name: selection.model,
...selection.reasoningEffort === undefined ? {} : { effort: selection.reasoningEffort },
}) },
runMode: plan?.active ? 'plan' : goal?.goal.phase === 'active' ? 'goal' : 'default',
running: state.running,
})
},
messageSubmitted: (submission) => { reportMessageSubmission(this.rootCtx, submission) },
inputTriggers: () => this.controller(actx),
popup: () => this.popup(actx),
inbox: session.projections.faceOf('inbox') as ObservableSnapshot<InboxState | undefined>,
@@ -7,7 +7,7 @@
* at Enter, so the editor can clear immediately and accept another message
* while earlier admissions remain in flight.
*/
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { InputSubmitMode, MessageSubmission } from '../contract/composer-submission.ts'
import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts'
/** Exhaustiveness backstop for the closed InputEvent union. */
@@ -77,7 +77,7 @@ export class SubmitMachine {
switch (ev.type) {
case 'draft-changed': return this.onDraftChanged(ev.draft)
case 'claim': return this.onClaim(ev.claim)
case 'enter': return this.onEnter(ev.mode, ev.draft)
case 'enter': return this.onEnter(ev.mode, ev.draft, ev.submission)
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
@@ -106,28 +106,28 @@ export class SubmitMachine {
}
/** Mint an attempt and controller without assigning its lifecycle owner. */
private mintAttempt(mode: InputSubmitMode, draft: string): {
private mintAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): {
readonly attempt: SubmitAttempt
readonly controller: AbortController
} {
const controller = new AbortController()
this.seq += 1
return {
attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode },
attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode, ...submission === undefined ? {} : { submission } },
controller,
}
}
/** Mint the frozen command/adjudication attempt. */
private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt {
const flight = this.mintAttempt(mode, draft)
private beginAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
const flight = this.mintAttempt(mode, draft, submission)
this.inflight = flight
return flight.attempt
}
/** Mint an ordinary send that leaves the phase plain. */
private beginDetached(mode: InputSubmitMode, draft: string): SubmitAttempt {
const flight = this.mintAttempt(mode, draft)
private beginDetached(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
const flight = this.mintAttempt(mode, draft, submission)
this.detached.set(flight.attempt.seq, flight.controller)
this.claim = undefined
this.phase = 'plain'
@@ -142,21 +142,21 @@ export class SubmitMachine {
]
}
private onEnter(mode: InputSubmitMode, draft: string): readonly InputEffect[] {
private onEnter(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): readonly InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode, draft)
const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'submitting'
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }]
}
const trimmed = draft.trim()
if (trimmed === '') return []
if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt(mode, draft)
const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft }]
}
return this.detachedEffects(this.beginDetached(mode, draft))
return this.detachedEffects(this.beginDetached(mode, draft, submission))
}
private onAdjudicated(
@@ -0,0 +1,22 @@
/** Maps ordinary message occurrences to Desktop product events. */
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { MessageSubmission } from '../contract/composer-submission.ts'
/**
* Report the original message occurrence without reading newer Session facts.
* @param ctx - client context.
* @param submission - original occurrence and Session snapshot.
*/
export function reportMessageSubmission(ctx: Context, submission: MessageSubmission): void {
const { state, timestamp, source, mode } = submission
if (state === undefined) return
const model = state.model
ctx.get('productAnalytics')?.track('send_button_click', {
...state.sessionId === undefined ? {} : { session_id: state.sessionId },
...model === undefined ? {} : { model_name: `${model.provider}/${model.name}`, ...model.effort === undefined ? {} : { thinking_effort: model.effort } },
run_mode: state.runMode,
...source === undefined ? {} : { submit_source: source },
submit_type: state.running ? mode : 'normal',
}, timestamp)
}
@@ -210,17 +210,20 @@ describe('reference submission', () => {
describe('submit transaction hardening', () => {
it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => {
const submitted = vi.fn()
let settle!: (outcome: SubmitOutcome) => void
const sink = vi.fn(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
const shell = new SessionInputShell({
actx: {} as Context,
defaultSink: sink,
commandAttachments,
messageSubmitted: submitted,
})
expect(shell.addAttachments(['img-1' as DraftAttachmentId])).toBe(true)
shell.submit('queue')
shell.submit('queue')
expect(sink).toHaveBeenCalledTimes(1)
expect(submitted).toHaveBeenCalledOnce()
settle({ kind: 'success' })
await vi.waitFor(() => {
expect(shell.snapshot.attachmentIds).toEqual([])
@@ -229,6 +232,9 @@ describe('submit transaction hardening', () => {
expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true)
shell.submit('queue')
expect(sink).toHaveBeenCalledTimes(2)
expect(submitted).toHaveBeenCalledTimes(2)
settle({ kind: 'success' })
shell.dispose()
})
it('retains an image-only rejection without duplicating its prompt error notice', async () => {
@@ -287,18 +293,18 @@ describe('submit transaction hardening', () => {
it('captures click and Enter submission intent before async admission, excluding empty submits', async () => {
const report = vi.fn()
const captureSubmit = vi.fn(() => report)
const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, captureSubmit })
const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false }))
const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, submissionState, messageSubmitted: report })
try {
shell.submit('queue', 'click')
expect(captureSubmit).not.toHaveBeenCalled()
expect(submissionState).not.toHaveBeenCalled()
shell.setDraft('first')
shell.submit('steer', 'click')
expect(captureSubmit).toHaveBeenCalledExactlyOnceWith('steer', 'click')
expect(report).toHaveBeenCalledWith(expect.objectContaining({ mode: 'steer', source: 'click' }))
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
shell.setDraft('second')
shell.submit('queue', 'enter')
expect(captureSubmit).toHaveBeenLastCalledWith('queue', 'enter')
expect(report).toHaveBeenLastCalledWith(expect.objectContaining({ mode: 'queue', source: 'enter' }))
expect(report).toHaveBeenCalledTimes(2)
} finally { shell.dispose() }
})
@@ -308,7 +314,7 @@ it.each(['capture', 'report'])('analytics %s failure does not interrupt a messag
const sink = vi.fn(async (): Promise<SubmitOutcome> => ({ kind: 'success' }))
const fail = () => { throw new Error('analytics unavailable') }
const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments,
captureSubmit: stage === 'capture' ? fail : () => fail })
...stage === 'capture' ? { submissionState: fail } : { messageSubmitted: fail } })
try {
shell.setDraft('message')
expect(() => { shell.submit('queue', 'click') }).not.toThrow()
@@ -321,7 +327,7 @@ it.each(['capture', 'report'])('analytics %s failure does not interrupt a messag
it.each(['handled', 'claim', 'message'] as const)('counts only a message after asynchronous slash adjudication: %s', async (kind) => {
const pending = Promise.withResolvers<PickOutcome>()
const report = vi.fn()
const captureSubmit = vi.fn(() => report)
const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false }))
const command = vi.fn(async (): Promise<SubmitOutcome> => ({ kind: 'success' }))
const sink = vi.fn(async (): Promise<SubmitOutcome> => ({ kind: 'success' }))
const inputTriggers: InputTriggerController = {
@@ -332,7 +338,7 @@ it.each(['handled', 'claim', 'message'] as const)('counts only a message after a
adjudicate: () => pending.promise,
}
const shell = new SessionInputShell({ actx: {} as Context, inputTriggers: () => inputTriggers,
captureSubmit, defaultSink: sink, commandAttachments })
submissionState, messageSubmitted: report, defaultSink: sink, commandAttachments })
try {
shell.setDraft('/compact')
shell.submit('queue', 'enter')
@@ -341,9 +347,56 @@ it.each(['handled', 'claim', 'message'] as const)('counts only a message after a
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')
expect(submissionState).toHaveBeenCalledOnce()
shell.setDraft('programmatic')
shell.actions.submit()
expect(captureSubmit).toHaveBeenLastCalledWith('queue', undefined)
expect(report).toHaveBeenLastCalledWith(expect.objectContaining({ mode: 'queue' }))
expect(report.mock.calls.at(-1)?.[0]).not.toHaveProperty('source')
} finally { shell.dispose() }
})
it('retains occurrence time and Session facts across arbitration and independent failed sends', async () => {
const pending = Promise.withResolvers<PickOutcome>()
const submitted = vi.fn()
const firstSend = Promise.withResolvers<SubmitOutcome>()
const secondSend = Promise.withResolvers<SubmitOutcome>()
const sink = vi.fn().mockReturnValueOnce(firstSend.promise).mockReturnValueOnce(secondSend.promise)
let running = true
let now = 100
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
const shell = new SessionInputShell({ actx: {} as Context, commandAttachments,
submissionState: () => Object.freeze({ runMode: running ? 'plan' : 'default', running }),
messageSubmitted: submitted,
inputTriggers: () => ({
launcher: { getSnapshot: () => null, subscribe: () => () => {} },
lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
track: () => {}, arbitrate: () => 'pass', onSpace: () => false,
serializeReference: async () => '', openReference: () => false, toggleSource: () => {},
adjudicate: () => pending.promise,
}),
defaultSink: sink,
})
try {
shell.setDraft('/ordinary')
shell.submit('steer', 'click')
running = false
now = 200
expect(submitted).not.toHaveBeenCalled()
pending.resolve(undefined)
await vi.waitFor(() => { expect(submitted).toHaveBeenCalledOnce() })
expect(submitted).toHaveBeenLastCalledWith({ timestamp: 100, source: 'click', mode: 'steer', state: { running: true, runMode: 'plan' } })
shell.setDraft('second')
shell.actions.submit()
expect(submitted).toHaveBeenLastCalledWith({ timestamp: 200, mode: 'queue', state: { running: false, runMode: 'default' } })
await vi.waitFor(() => { expect(sink).toHaveBeenCalledTimes(2) })
secondSend.resolve({ kind: 'error', text: 'second rejected' })
firstSend.resolve({ kind: 'error', text: 'first rejected' })
await Promise.all([firstSend.promise, secondSend.promise])
expect(submitted).toHaveBeenCalledTimes(2)
} finally {
firstSend.resolve({ kind: 'success' })
secondSend.resolve({ kind: 'success' })
shell.dispose()
clock.mockRestore()
}
})
@@ -891,7 +891,7 @@ it('captures run mode and queue intent at submission before later projection cha
b.shell.submit('queue', 'click')
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', submit_source: 'click', submit_type: 'queue',
})
}, expect.any(Number))
await b.runtime.sessions.setProjection('s1', 'plan', { active: false, pending: false })
expect(track).toHaveBeenCalledTimes(1)
expect(JSON.stringify(track.mock.calls)).not.toContain('private prompt')
@@ -1,4 +1,4 @@
import { installationAnalyticsInput } from './install-analytics.ts'
import { sanitizeInstallInput } from './sanitize-install-input.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
@@ -789,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: installationAnalyticsInput(spec), started: Date.now() }
this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: installationAnalyticsInput(spec), plugin_type: 'bundle' })
this.analyticsAttempt = { input: sanitizeInstallInput(spec), started: Date.now() }
this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(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)) {
@@ -935,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: installationAnalyticsInput(install.subject.spec), started: Date.now() }
if (this.ctx.get('productAnalytics')?.enabled) this.analyticsAttempt = { input: sanitizeInstallInput(install.subject.spec), started: Date.now() }
await this.startInstall(install.subject, pending)
}
@@ -5,7 +5,7 @@
* @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 {
export function sanitizeInstallInput(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]'
@@ -1,8 +1,8 @@
import { expect, it } from 'vitest'
import { installationAnalyticsInput } from '../src/client/install-analytics.ts'
import { sanitizeInstallInput } from '../src/client/sanitize-install-input.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)
expect(sanitizeInstallInput(spec)).toBe(spec)
})
it.each([
@@ -13,5 +13,5 @@ it.each([
['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)
expect(sanitizeInstallInput(spec)).toBe(expected)
})
@@ -13,6 +13,14 @@ import { OnboardingConfirmation, type OnboardingConfirmationKind } from './Onboa
import backIcon from './assets/onboarding-back.svg'
import css from './DesktopOnboarding.module.css'
const onboardingPages = {
welcome: 'onboarding_welcome',
credit: 'onboarding_recharge',
purpose: 'onboarding_use_case',
process: 'onboarding_process',
done: undefined,
} as const
/**
* @param props - durable choices, account state, localized copy and the shared recharge page channel.
* @returns the active first-run page.
@@ -45,10 +53,13 @@ export function DesktopOnboarding({
return () => { animation.cancel() }
}, [targetStep, step, state.visible])
useEffect(() => { heading.current?.focus() }, [step])
const pageName = step === 'welcome' ? 'onboarding_welcome' : step === 'credit' ? 'onboarding_recharge' : step === 'purpose' ? 'onboarding_use_case' : 'onboarding_process'
const pageName = onboardingPages[step]
const shownPage = useRef<string | null>(null)
useEffect(() => {
if (!state.visible || step === 'done' || exiting) { shownPage.current = null; return }
if (!state.visible || pageName === undefined || exiting) {
shownPage.current = null
return
}
if (state.status === 'loading') return
if (shownPage.current === pageName) return
shownPage.current = pageName
@@ -61,18 +72,18 @@ export function DesktopOnboarding({
shownPopup.current = dialog
track?.('onboarding_popup_view', { popup_name: dialog === 'skip' ? 'skip_setting' : 'skip_charge' })
}, [dialog, track, state.visible, state.status, step])
const click = (button_name: ProductEventMap['onboarding_page_click']['button_name']) => {
const selected_content = step === 'purpose' && progress.purpose !== null ? ({ office: 'office', development: 'code', both: 'code_office' } as const)[progress.purpose]
: step === 'process' && progress.process !== null ? ({ compact: 'focus_result', standard: 'key_detail', detailed: 'full_process' } as const)[progress.process] : undefined
track?.('onboarding_page_click', { page_name: pageName, button_name, ...selected_content === undefined ? {} : { selected_content } })
}
const popupClick = (button_name: ProductEventMap['onboarding_popup_click']['button_name']) => {
track?.('onboarding_popup_click', { popup_name: dialog === 'skip' ? 'skip_setting' : 'skip_charge', button_name })
}
if (state.visible && state.status === 'loading') return <OnboardingSurface>
<div className={css.loading} role="status">{t('onboardingLoading')}</div>
</OnboardingSurface>
if (!state.visible || step === 'done') return null
if (!state.visible || pageName === undefined) return null
const click = (button_name: ProductEventMap['onboarding_page_click']['button_name']) => {
const selected_content = step === 'purpose' && progress.purpose !== null ? ({ office: 'office', development: 'code', both: 'code_office' } as const)[progress.purpose]
: step === 'process' && progress.process !== null ? ({ compact: 'focus_result', standard: 'key_detail', detailed: 'full_process' } as const)[progress.process] : undefined
track?.('onboarding_page_click', { page_name: pageName, button_name, ...selected_content === undefined ? {} : { selected_content } })
}
const go = (next: 'welcome' | 'credit' | 'purpose' | 'process') => { void update({ step: next }) }
const canRecharge = openPlatformPage !== undefined
const recharge = () => {
@@ -108,7 +119,7 @@ export function DesktopOnboarding({
</section>
</OnboardingSurface>
{dialog !== null && <OnboardingConfirmation kind={dialog} t={t} busy={busy} canRecharge={canRecharge}
onClose={() => { setDialog(null) }} onKeepSetting={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }}
onClose={() => { setDialog(null) }} onContinueSetup={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }}
onSkip={() => { popupClick(dialog === 'skip' ? 'enter' : 'know'); void complete('skipped').then(() => { setDialog(null) }) }} />}
</>
}
@@ -7,13 +7,13 @@ import css from './DesktopOnboarding.module.css'
export type OnboardingConfirmationKind = 'credit' | 'credit-skip' | 'skip'
/** @param props - confirmation action, localized copy and navigation callbacks. @returns a focus-contained dialog. */
export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, onKeepSetting, onContinue, onRecharge, onSkip }:
export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, onContinueSetup, onContinue, onRecharge, onSkip }:
Pick<DesktopOnboardingProps, 't'> & {
kind: OnboardingConfirmationKind
busy: boolean
canRecharge: boolean
onClose: () => void
onKeepSetting: () => void
onContinueSetup: () => void
onContinue: () => void
onRecharge: () => void
onSkip: () => void
@@ -27,7 +27,7 @@ export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, on
</div>
<p>{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}</p>
<div className={css.confirmationActions}>
<Button className={css.dialogButton} variant="outline" disabled={busy} onClick={kind === 'credit-skip' ? onSkip : kind === 'credit' ? onContinue : onKeepSetting}>{t(credit ? 'onboardingUnderstood' : 'onboardingKeepSetting')}</Button>
<Button className={css.dialogButton} variant="outline" disabled={busy} onClick={kind === 'credit-skip' ? onSkip : kind === 'credit' ? onContinue : onContinueSetup}>{t(credit ? 'onboardingUnderstood' : 'onboardingKeepSetting')}</Button>
<Button className={css.dialogButton} variant="primary" disabled={busy || (credit && !canRecharge)} onClick={credit ? onRecharge : onSkip}>{t(credit ? 'onboardingGoTopUp' : 'onboardingEnter')}</Button>
</div>
</div>
@@ -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')
@@ -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',
@@ -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<boolean>',
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<void>',
description: 'Submit selected Desktop fields; missing identity is omitted and never generated.',
+6 -3
View File
@@ -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