Merge pull request #5136 from deepseek-harness/feat/desktop-product-analytics
feat(desktop): add product analytics event collection
This commit is contained in:
commit
2fc10a41c4
173 files changed
+2359
-167
No files matched your search
@@ -28,7 +28,11 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
|
||||
}
|
||||
writeFileSync(join(root, 'package.json'), '{"type":"module"}')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), `
|
||||
export const loadProfileDirectory = () => ({ skippedBundles: [] });
|
||||
export const reportSkippedBundles = () => {};
|
||||
export const loadLayeredEnv = () => ({});
|
||||
`)
|
||||
writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
|
||||
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
@@ -9,6 +9,7 @@ const state: AccountView = {
|
||||
}
|
||||
|
||||
class Account extends DeepSeekAccount {
|
||||
getDeviceIdentity = async () => ({ osVersion: 'test-os' })
|
||||
fail = false
|
||||
ended = Promise.withResolvers<undefined>()
|
||||
reading = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
/deepseek-harness-desktop:
|
||||
en: 45561fc0dd3a1b20
|
||||
zh: f4b03355d4bd0693
|
||||
en: b8e4f357cf330721
|
||||
zh: cdaaf35a061f41b1
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: c323e02e3f0305fe
|
||||
zh: 3c730db632136443
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited.
|
||||
|
||||
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
|
||||
|
||||
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。
|
||||
|
||||
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
|
||||
|
||||
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"vite": "^6.0.0",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*"
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -67,9 +67,13 @@ export interface DesktopAccountBackend {
|
||||
* @param listener - state recipient.
|
||||
* @param failed - stream failure recipient.
|
||||
* @param expired - live credential-expiry recipient.
|
||||
* @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false.
|
||||
* @returns stream disposer.
|
||||
*/
|
||||
watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void
|
||||
watch(
|
||||
listener: (state: AccountView) => void, failed: () => void, expired: () => void,
|
||||
onAnalyticsEnabledChanged?: (enabled: boolean) => void,
|
||||
): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,19 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
state: () => call('getState'),
|
||||
start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
|
||||
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }),
|
||||
watch(listener, failed, expired) {
|
||||
watch(listener, failed, expired, onAnalyticsEnabledChanged) {
|
||||
let closed = false
|
||||
let socket: WebSocket | undefined
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
const connect = (): void => {
|
||||
const streamId = randomUUID()
|
||||
const expiryStreamId = randomUUID()
|
||||
const analyticsPolicyStreamId = randomUUID()
|
||||
void cookies().then((cookie) => {
|
||||
if (closed) return
|
||||
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
|
||||
socket.on('open', () => {
|
||||
if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
|
||||
socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
|
||||
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))
|
||||
})
|
||||
@@ -106,6 +112,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
try {
|
||||
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
|
||||
if (frame.streamId === analyticsPolicyStreamId) {
|
||||
if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value)
|
||||
else onAnalyticsEnabledChanged?.(false)
|
||||
return
|
||||
}
|
||||
if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
|
||||
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
|
||||
if (frame.type === 'item') listener(accountView(frame.value))
|
||||
@@ -113,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
} catch { socket?.close() }
|
||||
})
|
||||
socket.on('error', () => { socket?.close() })
|
||||
socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
|
||||
}).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
|
||||
socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}
|
||||
connect()
|
||||
return () => { closed = true; clearTimeout(retry); socket?.close() }
|
||||
return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
const [expiryNotice, setExpiryNotice] = useState(false)
|
||||
const [page, setPage] = useState<Page>('entry')
|
||||
const pageRef = useRef<Page>('entry')
|
||||
const visiblePage = useRef<Page>('entry')
|
||||
const [attempt, setAttempt] = useState<AccountView['attempt']>(null)
|
||||
const attemptRef = useRef<AccountView['attempt']>(null)
|
||||
const [starting, setStarting] = useState(false)
|
||||
@@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
}, [api, m.welcomeTitle])
|
||||
|
||||
useEffect(() => {
|
||||
if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {})
|
||||
visiblePage.current = page
|
||||
if (page === 'key') input.current?.focus()
|
||||
else if (page === 'entry' && focusEntry.current) {
|
||||
focusEntry.current = false
|
||||
keyButton.current?.focus()
|
||||
}
|
||||
}, [page])
|
||||
}, [page, api])
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState !== 'copied' && copyState !== 'failed') return
|
||||
@@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
async function saveKey(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (busyRef.current) return
|
||||
void api.analytics?.('api_key_save_click', {})
|
||||
const value = draft.trim()
|
||||
if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value)
|
||||
|| ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) {
|
||||
@@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
<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 start() }}>{m.welcomeAuthRetry}</button>
|
||||
<button id="auth-api-key" className="secondary" type="button" hidden={!failed} onClick={() => { 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 start() }}>{m.welcomeSignIn}</button>
|
||||
<button ref={keyButton} id="api-key" className="secondary" type="button" onClick={() => { 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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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. */
|
||||
|
||||
@@ -44,7 +45,7 @@ import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './upd
|
||||
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
|
||||
import { desktopErrorState } from './startup-error.ts'
|
||||
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { desktopClientMetadata } from './client-metadata.ts'
|
||||
import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
|
||||
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
|
||||
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
|
||||
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
|
||||
@@ -376,6 +377,14 @@ async function main(): Promise<void> {
|
||||
const browserGuests = new DesktopBrowserGuests(() => hostUrl)
|
||||
let injections: readonly unknown[] = []
|
||||
let welcomeBackend: DesktopWelcomeBackend | undefined
|
||||
let reportedLaunch = false
|
||||
let analyticsEnabled = false
|
||||
const track = async <K extends keyof ProductEventMap>(eventName: K, attributes: ProductEventMap[K]): Promise<void> => {
|
||||
const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent
|
||||
try {
|
||||
if (analyticsEnabled) await welcomeBackend?.report(event)
|
||||
} catch (_error) { /* Analytics cannot interrupt native actions. */ }
|
||||
}
|
||||
let stopAccount: (() => void) | undefined
|
||||
let openedAttempt: string | undefined
|
||||
let returnedAttempt: string | undefined
|
||||
@@ -408,7 +417,7 @@ async function main(): Promise<void> {
|
||||
const backend = new DesktopBackendController((onFailure) => {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, process.env, onFailure,
|
||||
hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
return {
|
||||
@@ -419,6 +428,8 @@ 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
|
||||
stopAccount = accountBackend.watch((state) => {
|
||||
@@ -456,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
|
||||
@@ -567,6 +580,8 @@ async function main(): Promise<void> {
|
||||
const result = await updateDialog.show(parent, confirmation)
|
||||
if (result.response !== 0 || isMandatory()) return false
|
||||
}
|
||||
// The update lock rejects new HTTP requests, including analytics intake.
|
||||
await track('desktop_upgrade_install_restart_click', {})
|
||||
if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
|
||||
try {
|
||||
const stillActive = await host.updateTasks('lock')
|
||||
@@ -591,11 +606,14 @@ async function main(): Promise<void> {
|
||||
}
|
||||
return true
|
||||
},
|
||||
undefined, undefined, undefined,
|
||||
(success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
|
||||
)
|
||||
|
||||
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
|
||||
|
||||
const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => {
|
||||
void track('desktop_upgrade_click', {})
|
||||
updateJournal?.action('download-requested')
|
||||
const state = await updates.download(version)
|
||||
if (state.phase !== 'ready' || quitting) return state
|
||||
@@ -1085,12 +1103,15 @@ async function main(): Promise<void> {
|
||||
const showWelcome = (): Promise<void> => {
|
||||
if (quitting) return Promise.resolve()
|
||||
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) {
|
||||
if (!welcomeWindow.isVisible()) void track('auth_page_view', {})
|
||||
welcomeWindow.show()
|
||||
welcomeWindow.focus()
|
||||
return Promise.resolve()
|
||||
}
|
||||
openingWelcome ??= (async () => {
|
||||
welcomeWindow = await openWelcomeWindow(locale, {
|
||||
analytics: track,
|
||||
analyticsEnabled: () => Promise.resolve(analyticsEnabled),
|
||||
takeNotice: () => {
|
||||
const notice = pendingWelcomeNotice
|
||||
pendingWelcomeNotice = undefined
|
||||
|
||||
@@ -10,6 +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: 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>,
|
||||
|
||||
@@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator {
|
||||
* @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary.
|
||||
* @param enabled - Whether this process has a packaged update source.
|
||||
* @param currentVersion - Actual installed application version.
|
||||
* @param downloadResult - Once per completed download attempt, including platform preparation failures.
|
||||
*/
|
||||
constructor(
|
||||
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
|
||||
@@ -52,6 +53,7 @@ export class DesktopUpdateCoordinator {
|
||||
private readonly updater: AppUpdater = autoUpdater,
|
||||
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
|
||||
private readonly currentVersion: () => string = () => app.getVersion(),
|
||||
private readonly downloadResult?: (success: boolean, reason?: string) => void,
|
||||
) {
|
||||
if (updater === autoUpdater) {
|
||||
// electron-updater omits this internal transport property from its public declarations.
|
||||
@@ -108,9 +110,11 @@ export class DesktopUpdateCoordinator {
|
||||
try {
|
||||
await this.updater.downloadUpdate()
|
||||
if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness')
|
||||
this.downloadResult?.(true)
|
||||
return this.setState({ phase: 'ready', version })
|
||||
} catch (error) {
|
||||
this.downloaded = false
|
||||
this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed')
|
||||
return this.setState(this.failure(error, 'download'))
|
||||
}
|
||||
}).finally(() => { this.downloadOperation = undefined })
|
||||
|
||||
@@ -1,11 +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',
|
||||
@@ -20,8 +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 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. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
|
||||
/** Native welcome operations using the shared Web authentication and RPC APIs. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -13,7 +14,11 @@ 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>
|
||||
/** @returns Configured-key presence and the shared language preference, without credential values. */
|
||||
read(): Promise<WelcomeState>
|
||||
/** @returns The saved UI language without account or provider requests. */
|
||||
@@ -44,11 +49,14 @@ export async function connectDesktopWelcome(
|
||||
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
|
||||
await authenticated.body?.cancel()
|
||||
if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed')
|
||||
const invoke = async (request: { namespace: string; method: string; args: Record<string, unknown> }): Promise<unknown> => {
|
||||
const invoke = async (
|
||||
request: { namespace: string; method: string; args: Record<string, unknown> },
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> => {
|
||||
const rpcId = randomUUID()
|
||||
const method = `${request.namespace}/${request.method}`
|
||||
const response = await send(new URL(`/api/${method}`, origin).href, {
|
||||
method: 'POST', credentials: 'include', redirect: 'error',
|
||||
method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
|
||||
})
|
||||
@@ -115,6 +123,12 @@ 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: {} })
|
||||
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
|
||||
|
||||
@@ -57,14 +57,16 @@ let disposeActiveHandlers: (() => void) | undefined
|
||||
* @returns the visible window; a failed load destroys it before rejecting.
|
||||
*/
|
||||
export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise<BrowserWindow> {
|
||||
const window = new BrowserWindow(welcomeWindowOptions(process.platform, locale))
|
||||
const options = welcomeWindowOptions(process.platform, locale)
|
||||
const window = new BrowserWindow(options)
|
||||
disposeActiveHandlers?.()
|
||||
let active = true
|
||||
const disposeHandlers = (): void => {
|
||||
if (!active) return
|
||||
active = false
|
||||
for (const channel of [
|
||||
WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
|
||||
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)
|
||||
}
|
||||
@@ -76,6 +78,20 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
|
||||
throw new Error('desktop welcome: rejected action from an unowned frame')
|
||||
}
|
||||
}
|
||||
ipcMain.handle(WELCOME_IPC.analyticsEnabled, (event) => {
|
||||
assertSender(event)
|
||||
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) => {
|
||||
assertSender(event)
|
||||
@@ -108,6 +124,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
|
||||
throw error
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile.
|
||||
if (active && !window.isDestroyed()) window.show()
|
||||
if (active && !window.isDestroyed()) {
|
||||
window.show()
|
||||
void operations.analytics?.('auth_page_view', {})
|
||||
}
|
||||
return window
|
||||
}
|
||||
@@ -49,14 +49,17 @@ 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 onAnalyticsEnabledChanged = vi.fn()
|
||||
const snapshot = { status: 'signed-out', attempt: null,
|
||||
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
|
||||
const received = Promise.withResolvers<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, onAnalyticsEnabledChanged,
|
||||
)
|
||||
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 +67,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(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: false }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: 'invalid policy' }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
|
||||
expect(expired).not.toHaveBeenCalled()
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' }))
|
||||
await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() })
|
||||
|
||||
@@ -172,6 +172,9 @@ const harness = await vi.hoisted(async () => {
|
||||
}),
|
||||
})
|
||||
let accountListener: ((state: AccountView) => void) | undefined
|
||||
let analyticsEnabled = true
|
||||
let analyticsEnabledListener: ((enabled: boolean) => void) | undefined
|
||||
const analytics = vi.fn(async (_event: unknown) => {})
|
||||
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
|
||||
const trays: FakeTray[] = []
|
||||
class FakeTray extends EventEmitter {
|
||||
@@ -184,14 +187,21 @@ const harness = await vi.hoisted(async () => {
|
||||
const shellDialog = { isOpen: false, focus: vi.fn() }
|
||||
return {
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, shellDialog,
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
trays, FakeTray, backgroundNotice, shellDialog,
|
||||
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
platformCloseAndWait,
|
||||
|
||||
watchAccount: (listener: (state: AccountView) => void) => {
|
||||
get analyticsEnabled() { return analyticsEnabled },
|
||||
set analyticsEnabled(value: boolean) { analyticsEnabled = value; analyticsEnabledListener?.(value) },
|
||||
watchAccount: (
|
||||
listener: (state: AccountView) => void, _failed: () => void, _expired: () => void,
|
||||
onAnalyticsEnabledChanged?: (enabled: boolean) => void,
|
||||
) => {
|
||||
analyticsEnabledListener = onAnalyticsEnabledChanged
|
||||
accountListener = listener
|
||||
return () => { accountListener = undefined }
|
||||
return () => { accountListener = undefined; analyticsEnabledListener = undefined }
|
||||
},
|
||||
publishAccount(state: AccountView) { accountListener?.(state) },
|
||||
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
|
||||
@@ -343,6 +353,8 @@ 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>,
|
||||
save: async () => ({ ok: true }),
|
||||
@@ -396,6 +408,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')
|
||||
harness.analyticsEnabled = true
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -1638,6 +1651,23 @@ describe('desktop main startup', () => {
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it.each(['accepted', 'failed'] as const)('settles analytics intake before locking API admission and continues after intake failure: %s', async (outcome) => {
|
||||
const host = await readyForUpdate()
|
||||
await vi.waitFor(() => { expect(harness.analytics).toHaveBeenCalledWith(expect.objectContaining({ eventName: 'desktop_app_launch' })) })
|
||||
const intake = Promise.withResolvers<undefined>()
|
||||
harness.analytics.mockImplementationOnce(() => intake.promise)
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
const preparing = harness.prepareUpdate()
|
||||
await vi.waitFor(() => { expect(harness.analytics).toHaveBeenLastCalledWith(expect.objectContaining({ eventName: 'desktop_upgrade_install_restart_click' })) })
|
||||
expect(host.updateTasks.mock.calls).toEqual([['inspect']])
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
if (outcome === 'accepted') intake.resolve(undefined)
|
||||
else intake.reject(new Error('local analytics intake timed out'))
|
||||
await host.stopping.promise
|
||||
host.exited.resolve()
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reports a Platform storage cleanup failure as preparation failure without stopping the Host', async () => {
|
||||
const host = await readyForUpdate()
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
@@ -2034,7 +2064,9 @@ describe('desktop main startup', () => {
|
||||
primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
|
||||
profile: 'desktop-test-profile',
|
||||
})
|
||||
expect(harness.hosts[0]!.environment).toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment).not.toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3')
|
||||
expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} })
|
||||
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
expect(window.urls).toEqual(['dsh-app://app/'])
|
||||
@@ -2159,3 +2191,15 @@ it.each([['light', false], ['dark', true]] as const)('opens Platform authorizati
|
||||
harness.publishAccount(state)
|
||||
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
|
||||
})
|
||||
|
||||
it('disables native product events for a disabled Desktop launch', async () => {
|
||||
harness.analyticsEnabled = false
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
harness.prepared.resolve()
|
||||
await harness.hostStarted.promise
|
||||
harness.hosts[0]!.ready.resolve()
|
||||
await harness.navigated.promise
|
||||
expect(harness.analytics).not.toHaveBeenCalled()
|
||||
harness.analyticsEnabled = true
|
||||
})
|
||||
@@ -61,14 +61,15 @@ function fixture() {
|
||||
})
|
||||
const quitAndInstall = vi.fn()
|
||||
const beforeRestart = vi.fn(async () => true)
|
||||
const downloadResult = vi.fn()
|
||||
const states: DesktopUpdateState[] = []
|
||||
const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater
|
||||
const coordinator = new DesktopUpdateCoordinator(
|
||||
(state) => { states.push(state); return state },
|
||||
beforeRestart, updater, () => true, () => '1.1.0-alpha.1',
|
||||
beforeRestart, updater, () => true, () => '1.1.0-alpha.1', downloadResult,
|
||||
)
|
||||
coordinators.push(coordinator)
|
||||
return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart }
|
||||
return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult }
|
||||
}
|
||||
|
||||
describe('desktop update coordinator', () => {
|
||||
@@ -222,3 +223,22 @@ describe('desktop update coordinator', () => {
|
||||
expect(f.events.listenerCount('download-progress')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('reports one download result when callers share an operation and none for cached readiness', async () => {
|
||||
const f = fixture()
|
||||
await f.coordinator.check()
|
||||
await Promise.all([f.coordinator.download('1.1.0-rc.2'), f.coordinator.download('1.1.0-rc.2')])
|
||||
await f.coordinator.download('1.1.0-rc.2')
|
||||
expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(true)
|
||||
})
|
||||
|
||||
|
||||
it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), new Error('private URL')])('reports safe download failure classification: %s', async (error) => {
|
||||
const f = fixture()
|
||||
await f.coordinator.check()
|
||||
f.downloadUpdate.mockRejectedValueOnce(error)
|
||||
await f.coordinator.download('1.1.0-rc.2')
|
||||
expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed')
|
||||
expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private')
|
||||
})
|
||||
@@ -20,6 +20,8 @@ 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 }]))
|
||||
return Response.json({ type: 'server-response', rpcId, result: { ok: true, value } })
|
||||
@@ -34,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 })
|
||||
@@ -90,6 +93,19 @@ describe('desktop welcome Web operations', () => {
|
||||
await expect(backend.read()).rejects.toThrow('Web RPC failed')
|
||||
})
|
||||
|
||||
|
||||
it('submits native analytics through authenticated RPC with a bounded request', async () => {
|
||||
const host = transport()
|
||||
const backend = await connectDesktopWelcome(url, host.send)
|
||||
const event = { eventName: 'desktop_app_launch' as const, timestamp: 100, attributes: {} }
|
||||
await backend.report(event)
|
||||
const [input, init] = host.send.mock.calls.at(-1)!
|
||||
expect(input).toBe('http://127.0.0.1:19387/api/productAnalytics/report')
|
||||
expect(init?.credentials).toBe('include')
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(JSON.parse(init!.body as string)).toMatchObject({ payload: { args: { event } } })
|
||||
})
|
||||
|
||||
it('refuses an unauthenticated Web launch', async () => {
|
||||
const send = vi.fn<Parameters<typeof connectDesktopWelcome>[1]>(async () => new Response(null, { status: 401 }))
|
||||
await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed')
|
||||
|
||||
@@ -16,6 +16,8 @@ function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise<WelcomeNotic
|
||||
const stopAccount = vi.fn()
|
||||
const api = {
|
||||
takeNotice,
|
||||
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 })),
|
||||
@@ -326,3 +328,16 @@ it.each(['zh-CN', 'en'])('returns from completed sign-in to the initial page aft
|
||||
expect(view.document.querySelector<HTMLElement>('#auth-page')!.hidden).toBe(true)
|
||||
await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`)
|
||||
})
|
||||
|
||||
it('reports each return to the welcome entry once, including account cancellation', async () => {
|
||||
const view = mount()
|
||||
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(['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(['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()
|
||||
@@ -175,6 +176,7 @@ it.each([false, true])('starts welcome onboarding without carrying update focus
|
||||
state.accountState.mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } })
|
||||
if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] })
|
||||
vi.useFakeTimers()
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
|
||||
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile')
|
||||
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
|
||||
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
@@ -15,6 +15,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/credentials/deepseek-account"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/product-analytics/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/product-analytics/tsconfig.host.json"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: 8d7ae3eb1265031d
|
||||
zh: 970ce559f5f95a65
|
||||
en: 00ee1236e43f5538
|
||||
zh: aed1f351bf657eea
|
||||
@@ -112,6 +112,8 @@ flowchart LR
|
||||
pkg_api_account_controller["api-account-controller"]
|
||||
pkg_authorization["authorization"]
|
||||
svc_authorization["ctx.authorization<br/>Authorization flow registry"]
|
||||
pkg_client_product_analytics["client-product-analytics"]
|
||||
svc_productAnalytics["ctx.productAnalytics<br/>Desktop interaction collection"]
|
||||
pkg_otel["otel"]
|
||||
svc_otel["ctx.otel<br/>Shared OTel reporting channels"]
|
||||
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
|
||||
@@ -298,6 +300,7 @@ flowchart LR
|
||||
pkg_client_connection --> svc_connection
|
||||
pkg_client_file_upload --> svc_fileUploads
|
||||
pkg_client_modules --> svc_clientModules
|
||||
pkg_client_product_analytics --> svc_productAnalytics
|
||||
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
|
||||
pkg_command_feedback --> svc_sessionFeedback
|
||||
pkg_commands --> svc_commands
|
||||
@@ -610,6 +613,7 @@ flowchart LR
|
||||
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. |
|
||||
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. |
|
||||
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
|
||||
| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy. |
|
||||
| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | Product analytics and Session feedback adapters create independent reporting channels through one injected service. |
|
||||
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing. |
|
||||
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
|
||||
|
||||
@@ -114,6 +114,8 @@ flowchart LR
|
||||
pkg_api_account_controller["api-account-controller"]
|
||||
pkg_authorization["authorization"]
|
||||
svc_authorization["ctx.authorization<br/>Authorization flow registry"]
|
||||
pkg_client_product_analytics["client-product-analytics"]
|
||||
svc_productAnalytics["ctx.productAnalytics<br/>Desktop interaction collection"]
|
||||
pkg_otel["otel"]
|
||||
svc_otel["ctx.otel<br/>Shared OTel reporting channels"]
|
||||
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
|
||||
@@ -300,6 +302,7 @@ flowchart LR
|
||||
pkg_client_connection --> svc_connection
|
||||
pkg_client_file_upload --> svc_fileUploads
|
||||
pkg_client_modules --> svc_clientModules
|
||||
pkg_client_product_analytics --> svc_productAnalytics
|
||||
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
|
||||
pkg_command_feedback --> svc_sessionFeedback
|
||||
pkg_commands --> svc_commands
|
||||
@@ -612,6 +615,7 @@ flowchart LR
|
||||
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 |
|
||||
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 |
|
||||
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
|
||||
| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | 根据 Host 动态采集策略接收指定桌面事件、补充可用登录身份并监听实时压缩。 |
|
||||
| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | 产品埋点和 Session 反馈适配器通过同一个注入服务创建独立上报通道。 |
|
||||
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | 通过 OTLP/HTTP 发送显式提交的分析事件;仅挂载插件不会采集信息。 |
|
||||
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
|
||||
|
||||
@@ -493,6 +493,26 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-hmr -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
|
||||
<a id="deepseek-aidsh-client-product-analytics"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-client-product-analytics`
|
||||
|
||||
- `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 {
|
||||
/** Live application collection policy; ordinary Web does not mount this service. */
|
||||
enabled: Volatile<boolean>
|
||||
/** Running Desktop release, absent when unavailable. */
|
||||
appVersion?: string
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
|
||||
<a id="deepseek-aidsh-client-shortcuts"></a>
|
||||
|
||||
|
||||
@@ -495,6 +495,26 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-hmr -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
|
||||
<a id="deepseek-aidsh-client-product-analytics"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-client-product-analytics`
|
||||
|
||||
- `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 {
|
||||
/** Live application collection policy; ordinary Web does not mount this service. */
|
||||
enabled: Volatile<boolean>
|
||||
/** Running Desktop release, absent when unavailable. */
|
||||
appVersion?: string
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
|
||||
<a id="deepseek-aidsh-client-shortcuts"></a>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ flowchart TD
|
||||
pkg_client_hmr["client-hmr"]
|
||||
pkg_client_locale["client-locale"]
|
||||
pkg_client_modules["client-modules"]
|
||||
pkg_client_product_analytics["client-product-analytics"]
|
||||
pkg_client_resources["client-resources"]
|
||||
pkg_client_shortcuts["client-shortcuts"]
|
||||
pkg_client_store["client-store"]
|
||||
@@ -1401,6 +1402,7 @@ flowchart TD
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | — |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | — |
|
||||
| [`client-modules`](../packages/client/modules) | `client` | — |
|
||||
| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
|
||||
| [`client-resources`](../packages/client/resources) | `client` | — |
|
||||
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
|
||||
| [`client-store`](../packages/client/store) | `client` | — |
|
||||
|
||||
@@ -156,6 +156,7 @@ flowchart TD
|
||||
pkg_client_hmr["client-hmr"]
|
||||
pkg_client_locale["client-locale"]
|
||||
pkg_client_modules["client-modules"]
|
||||
pkg_client_product_analytics["client-product-analytics"]
|
||||
pkg_client_resources["client-resources"]
|
||||
pkg_client_shortcuts["client-shortcuts"]
|
||||
pkg_client_store["client-store"]
|
||||
@@ -1403,6 +1404,7 @@ flowchart TD
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | — |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | — |
|
||||
| [`client-modules`](../packages/client/modules) | `client` | — |
|
||||
| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
|
||||
| [`client-resources`](../packages/client/resources) | `client` | — |
|
||||
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
|
||||
| [`client-store`](../packages/client/store) | `client` | — |
|
||||
|
||||
@@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise<void>
|
||||
* @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
|
||||
*/
|
||||
abstract getPlatformSession(): Promise<PlatformSession | null>
|
||||
|
||||
/**
|
||||
* Read existing login identity without creating a device or returning credentials.
|
||||
* @returns optional device/account identifiers and the provider's OS version string.
|
||||
*/
|
||||
abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
|
||||
|
||||
@@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise<void>
|
||||
* @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
|
||||
*/
|
||||
abstract getPlatformSession(): Promise<PlatformSession | null>
|
||||
|
||||
/**
|
||||
* Read existing login identity without creating a device or returning credentials.
|
||||
* @returns optional device/account identifiers and the provider's OS version string.
|
||||
*/
|
||||
abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/product-telemetry.md
|
||||
/product-telemetry:
|
||||
en: e1a8950c3b4743ed
|
||||
zh: 3fff7e01a8f271e1
|
||||
en: 04e9a32dfbe69355
|
||||
zh: 0e6c78274dd97466
|
||||
/product-telemetry/record-types:
|
||||
en: bf5eadedbc896f5a
|
||||
zh: 158fce1609a85d6d
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](product-telemetry.zh.md)
|
||||
|
||||
The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. No Session data or identifiers are collected automatically.
|
||||
The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. The exporter collects no Session data or identifiers automatically. The [Desktop analytics consumer](../../packages/client/product-analytics/README.md) selects interaction and live compaction events, adds available login identity, and follows the Desktop launch-time collection switch. Ordinary Web clients do not collect product events.
|
||||
|
||||
`ProductTelemetryRecord` requires an event name, string body, and Unix-millisecond timestamp. Attributes accept scalar strings, numbers, booleans, and one-level objects of those values (`ProductTelemetryScalar`). Optional severity uses OTel severity numbers and defaults to INFO. Observation time is assigned when the record is enqueued.
|
||||
|
||||
@@ -30,6 +30,36 @@ Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/hos
|
||||
|
||||
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
|
||||
|
||||
<a id="ctxproductanalytics--productanalytics"></a>
|
||||
|
||||
### `ctx.productAnalytics` — `ProductAnalytics`
|
||||
|
||||
Authenticated event intake; disabled instances do not inspect identity or accept new events.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Read the collection policy.
|
||||
* @returns whether this Host currently accepts Desktop analytics.
|
||||
*/
|
||||
@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.
|
||||
* @returns after local submission; no delivery or warehouse acknowledgement.
|
||||
*/
|
||||
@Remote async report(event: ProductEvent): Promise<void>
|
||||
```
|
||||
|
||||
Source: [`packages/client/product-analytics/src/index.ts`](../../packages/client/product-analytics/src/index.ts)
|
||||
|
||||
<a id="ctxproducttelemetry--producttelemetry"></a>
|
||||
|
||||
### `ctx.productTelemetry` — `ProductTelemetry`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](product-telemetry.md) | 中文
|
||||
|
||||
[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。不自动采集 Session 数据或标识。
|
||||
[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。导出器不自动采集 Session 数据或标识。[桌面埋点消费方](../../packages/client/product-analytics/README.zh.md)选择交互与实时压缩事件,补充可用的登录身份,并遵守桌面启动时的采集开关。普通 Web 客户端不采集产品事件。
|
||||
|
||||
`ProductTelemetryRecord` 要求事件名称、字符串 body 和 Unix 毫秒时间戳。属性接受字符串、数字、布尔标量,以及这些值组成的单层对象(`ProductTelemetryScalar`)。可选的严重程度使用 OTel 严重程度数字,默认为 INFO。记录入队时填写观测时间。
|
||||
|
||||
@@ -30,6 +30,36 @@ Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/hos
|
||||
|
||||
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
|
||||
|
||||
<a id="ctxproductanalytics--productanalytics"></a>
|
||||
|
||||
### `ctx.productAnalytics` — `ProductAnalytics`
|
||||
|
||||
Authenticated event intake; disabled instances do not inspect identity or accept new events.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Read the collection policy.
|
||||
* @returns whether this Host currently accepts Desktop analytics.
|
||||
*/
|
||||
@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.
|
||||
* @returns after local submission; no delivery or warehouse acknowledgement.
|
||||
*/
|
||||
@Remote async report(event: ProductEvent): Promise<void>
|
||||
```
|
||||
|
||||
Source: [`packages/client/product-analytics/src/index.ts`](../../packages/client/product-analytics/src/index.ts)
|
||||
|
||||
<a id="ctxproducttelemetry--producttelemetry"></a>
|
||||
|
||||
### `ctx.productTelemetry` — `ProductTelemetry`
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 1c32778606f3027a
|
||||
zh: db7994fdb50333c8
|
||||
/deepseek-ai-dsh-api-remotes:
|
||||
en: b5349178e12f23fe
|
||||
zh: dfdb4de9d99fbc7b
|
||||
en: f0e152bd9c846ec4
|
||||
zh: dba8ab5fa1181276
|
||||
/deepseek-ai-dsh-api-remotes/summary:
|
||||
en: 3c54c157bb823fd0
|
||||
zh: 0db6778e94ae671c
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-reference"
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
|
||||
|
||||
## Summary
|
||||
|
||||
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns the forwarded-event selection and registers its application event source with API Gateway; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-reference"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
|
||||
|
||||
## 概述
|
||||
|
||||
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口拥有转发事件名单并向 API Gateway 注册应用事件 source;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-office-to-pdf": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-account-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*"
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Platform-neutral assembly of generated Host Remote contributions. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import productAnalyticsRemote from '@deepseek-ai/dsh-client-product-analytics/remote'
|
||||
export type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
|
||||
import agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote'
|
||||
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
|
||||
import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote'
|
||||
@@ -175,7 +177,8 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
const disposers: Array<() => Promise<void>> = []
|
||||
try {
|
||||
for (const contribution of [
|
||||
agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, scheduleRemote,
|
||||
productAnalyticsRemote, agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote,
|
||||
goalsRemote, llmRemote, dynamicRemote, scheduleRemote,
|
||||
pluginInventoryRemote, pluginManagerRemote, pluginRegistryProbeRemote, messageFeedbackRemote, sessionFeedbackRemote,
|
||||
fileUploadsRemote, sessionReferencesRemote,
|
||||
permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../client/product-analytics/tsconfig.client.json" },
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 82789442228fc62a
|
||||
zh: f1d511ad838187de
|
||||
/session-controller:
|
||||
en: fb78499ee63f41b9
|
||||
zh: 6fff916b1fa26314
|
||||
en: c7a4c58ea6d3437b
|
||||
zh: 45c2b472a074bb1b
|
||||
/session-controller/summary:
|
||||
en: 4914824c347fdab5
|
||||
zh: 684a7b7fa970c39f
|
||||
|
||||
@@ -6,6 +6,8 @@ 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.
|
||||
|
||||
## Summary
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, human background-job kill, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
|
||||
|
||||
@@ -6,6 +6,8 @@ kind: "package-reference"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。
|
||||
|
||||
## 概述
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、人工后台任务终止、工作区路径打开、用户可调用 skill(技能)发现和 Agent(智能体)范围的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
|
||||
|
||||
@@ -125,11 +125,16 @@ export interface ISessions {
|
||||
* seq (a real event seq the caller already knows; a cut inside an open
|
||||
* turn is balanced Host-side with synthetic closers, and omission selects
|
||||
* the latest completed-turn prefix), and whether to increment an
|
||||
* inherited durable title before resolving.
|
||||
* inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.
|
||||
* @returns the child session id.
|
||||
* @throws when the fork fails, or when a requested child-title rename fails after creation.
|
||||
*/
|
||||
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
|
||||
fork(opts: {
|
||||
sessionId: SessionId
|
||||
atSeq?: number
|
||||
increaseTitle?: boolean
|
||||
onCreated?: (childId: SessionId) => void
|
||||
}): Promise<SessionId>
|
||||
/**
|
||||
* Borrow an already-retained Agent-scoped Context without extending its lifetime.
|
||||
* @param id - session id.
|
||||
|
||||
@@ -452,6 +452,7 @@ export class ClientSessions implements ISessions {
|
||||
sessionId: SessionId
|
||||
atSeq?: number
|
||||
increaseTitle?: boolean
|
||||
onCreated?: (childId: SessionId) => void
|
||||
}): Promise<SessionId> {
|
||||
const sourceTitle = opts.increaseTitle
|
||||
? this.list.getSnapshot().byId[opts.sessionId]?.title
|
||||
@@ -463,6 +464,7 @@ export class ClientSessions implements ISessions {
|
||||
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
const childId = result.value.sessionId
|
||||
opts.onCreated?.(childId)
|
||||
if (sourceTitle !== undefined) {
|
||||
const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle))
|
||||
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
|
||||
|
||||
@@ -983,8 +983,10 @@ describe('fork', () => {
|
||||
b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
|
||||
b.mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
|
||||
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
|
||||
const onCreated = vi.fn()
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true, onCreated }))
|
||||
.rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
|
||||
expect(onCreated).toHaveBeenCalledExactlyOnceWith('child')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]).toBeDefined()
|
||||
expect(b.svc.binding(sid('child'))).toBeUndefined()
|
||||
expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
|
||||
|
||||
@@ -194,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.
|
||||
|
||||
@@ -194,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 保持原生解析。
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: af2e4e5296596e8f
|
||||
zh: 70c0b5ac93209d00
|
||||
/deepseek-ai-dsh-web-app:
|
||||
en: f4da95d498221fcd
|
||||
zh: 6cc704e7aa6f376d
|
||||
en: 8523b283e794cf59
|
||||
zh: 7764704f7e98f19b
|
||||
/deepseek-ai-dsh-web-app/summary:
|
||||
en: 0ebb6c3ca8c0a84e
|
||||
zh: 4d85e02c769ddae0
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-bundle"
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
|
||||
|
||||
## Summary
|
||||
|
||||
Run `dsh --profile web` to open an interactive browser GUI with chat, model and settings management, and session history. It uses the same model access, tools, and safety defaults as other dsh surfaces. Startup prints an authenticated URL and normally opens it in the default browser; SSH sessions and `--no-open` leave the URL for manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Choose this package for interactive browser work; use `dsh-headless` for one-shot command-line tasks.
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-bundle"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
|
||||
|
||||
## 概述
|
||||
|
||||
运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印带认证信息的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。
|
||||
|
||||
@@ -42,6 +42,24 @@
|
||||
# `dsh.client` rows are the browser roster the modules node half scans into
|
||||
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
|
||||
- insert:
|
||||
- id: desktop-product-telemetry
|
||||
name: '@deepseek-ai/dsh-host-product-telemetry-otel'
|
||||
disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
|
||||
config:
|
||||
timeoutMillis: 1000
|
||||
exportTimeoutMillis: 1500
|
||||
shutdownTimeoutMillis: 2000
|
||||
serviceName: deepseek-harness-desktop
|
||||
serviceVersion: !!js process.env.DSH_CLIENT_VERSION
|
||||
endpoint: !!js process.env.DSH_PRODUCT_ANALYTICS_OTLP_URL
|
||||
|
||||
- id: product-analytics
|
||||
name: '@deepseek-ai/dsh-client-product-analytics'
|
||||
disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
|
||||
config:
|
||||
enabled: true
|
||||
appVersion: !!js process.env.DSH_CLIENT_VERSION
|
||||
|
||||
# Host-owned opt-in sampled when a new Web session receives its preset
|
||||
# delegation tools. The Plugins page edits this settings namespace.
|
||||
- id: subagent-model-selection-settings
|
||||
|
||||
@@ -173,7 +173,9 @@
|
||||
"@deepseek-ai/dsh-api-account-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
@@ -193,6 +195,7 @@
|
||||
"@deepseek-ai/dsh-shell-env": "workspace:*",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:*",
|
||||
"@deepseek-ai/libreoffice-kit": "^0.1.1"
|
||||
"@deepseek-ai/libreoffice-kit": "^0.1.1",
|
||||
"@deepseek-ai/dsh-otel": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import OTel from '@deepseek-ai/dsh-otel'
|
||||
/** Desktop-only collector policy and bounded shutdown against an unresponsive receiver. */
|
||||
import { createServer } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import * as Telemetry from '@deepseek-ai/dsh-host-product-telemetry-otel'
|
||||
import Analytics from '@deepseek-ai/dsh-client-product-analytics'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
afterEach(() => { vi.unstubAllEnvs() })
|
||||
|
||||
it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop launch: %s', async (profile) => {
|
||||
const received = Promise.withResolvers<undefined>()
|
||||
const server = createServer((req) => { req.resume(); req.once('end', () => { received.resolve(undefined) }) })
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
onTestFinished(async () => { const closed = once(server, 'close'); server.close(); server.closeAllConnections(); await closed })
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('missing collector address')
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', 'test-version')
|
||||
vi.stubEnv('DSH_PRODUCT_ANALYTICS_OTLP_URL', `http://127.0.0.1:${address.port}/logs`)
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
ctx.provide('profileContext', {
|
||||
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)
|
||||
await ctx.plugin(OTel)
|
||||
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' || 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()
|
||||
}
|
||||
}
|
||||
const emit = vi.spyOn(ctx.productTelemetry, 'emit')
|
||||
await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
|
||||
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ attributes: { app_version: 'test-version' } }))
|
||||
lifetime.abort()
|
||||
await policy.return?.()
|
||||
ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() })
|
||||
const disposal = entry.fiber!.dispose()
|
||||
await received.promise
|
||||
await disposal
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/product-analytics/README.md
|
||||
/:
|
||||
en: 385fffb5f6f81222
|
||||
zh: ea1d23fccd3b2b84
|
||||
/deepseek-ai-dsh-client-product-analytics:
|
||||
en: bfcd910889485da9
|
||||
zh: 74e27b92b9fafc33
|
||||
/deepseek-ai-dsh-client-product-analytics/summary:
|
||||
en: 4b48b9e5de3a0e97
|
||||
zh: cc06154eef4c4f6a
|
||||
/deepseek-ai-dsh-client-product-analytics/table-of-contents:
|
||||
en: 2fee9973f19f9401
|
||||
zh: 8ab4b55413cb8408
|
||||
/deepseek-ai-dsh-client-product-analytics/use-this-package:
|
||||
en: 6bd7bf60e279ba1c
|
||||
zh: afd55f06585c6462
|
||||
/deepseek-ai-dsh-client-product-analytics/understand-the-implementation:
|
||||
en: 416b7e925f0852c9
|
||||
zh: c0b83bacf18e91b1
|
||||
/deepseek-ai-dsh-client-product-analytics/model-experience:
|
||||
en: 291751b16b8a37e7
|
||||
zh: 3b38008f4364ec41
|
||||
/deepseek-ai-dsh-client-product-analytics/model-experience/kv-cache-effect:
|
||||
en: 1f5125831808e7a9
|
||||
zh: 97566fdde332df39
|
||||
/deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work:
|
||||
en: a33ad31745fd5240
|
||||
zh: 8b579cfc4c9b8155
|
||||
/deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work/dev-note:
|
||||
en: 0a1253c6f7b5e8b9
|
||||
zh: 9f56528562c7c914
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: "Configure Desktop product analytics, identity fields, and event timing without collecting Web usage."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-client-product-analytics
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Desktop reports selected interactions through the existing OTel product exporter by default, without a user-facing control. Ordinary Web clients never submit these events, and missing login identity is omitted.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
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 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.
|
||||
|
||||
Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget.
|
||||
|
||||
The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup reports `button_name=close`. Funded Continue uses `continue`. Message submission retains its original occurrence timestamp and captures `msg_type=default|queue|steer`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. Message submissions and model or effort switches omit `session_id` while the Session is blank. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing.
|
||||
|
||||
Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packages use `bundle`. Installation reports `is_success`; cancellation uses `is_success=false` and `error_reason=user_cancelled`, and an absent recovery result uses `is_success=false` and `error_reason=unknown_result`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result reports `unknown_result`.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<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 synchronized Host policy. The sender retains its own RPC context when called by other plugins. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host enriches identity immediately before queueing. It observes live compaction events without replaying Session history. No runtime invariant companion is published because collection has no independent delivery acknowledgement to compare.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as analytics adds no model context or Session events.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; collection does not modify model requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
Delivery is best effort.
|
||||
|
||||
- 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.
|
||||
|
||||
### Dev Note
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: "配置桌面端产品埋点、身份字段和事件时机,不采集 Web 使用情况。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-client-product-analytics
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
桌面端默认通过现有 OTel 产品导出器上报指定交互,不提供用户操作入口。普通 Web 客户端不会提交这些事件,缺失的登录身份字段会省略。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [了解实现](#understand-the-implementation)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
桌面端同时装配 Analytics 与必需的 Telemetry 导出器。`product-analytics` settings 命名空间持有动态 `enabled` 字段,默认 `true`,通过现有 Cordis Config / settings 机制配置,暂不提供用户操作入口。普通 Web 不装配这两个服务。关闭后不读取埋点身份、不接收新事件;导出器仍保持挂载,已入队事件可以继续导出。会话反馈遥测采用独立策略。
|
||||
|
||||
渲染端和 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、账户令牌、提示词和模型回复都不是事件字段。
|
||||
|
||||
Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。
|
||||
|
||||
认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗上报 `button_name=close`。有余额时的继续按钮使用 `continue`。消息提交保留最初发生时间,并在异步命令裁决前采集 `msg_type=default|queue|steer`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。计划模式优先于活跃目标。采集和上报异常不会中断提交。消息提交以及模型或思考强度切换在空白会话中均省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。
|
||||
|
||||
只有 `plugin_toggle` 携带 `plugin_type`:插件条目使用 `plugin`,插件包使用 `bundle`。安装通过 `is_success` 表达结果;取消使用 `is_success=false` 和 `error_reason=user_cancelled`,恢复查询确认无结果时使用 `is_success=false` 和 `error_reason=unknown_result`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才上报 `unknown_result`。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 了解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。发送器在被其他插件调用时保留自身的 RPC 上下文。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。本包不发布运行时不变量伴随模块,因为采集没有可用于比较的独立交付确认。
|
||||
|
||||
</details>
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无;埋点不增加模型上下文或会话事件。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;采集不修改模型请求。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
交付采用尽力而为的方式。
|
||||
|
||||
- 关闭只停止新采集,不清空导出器队列。
|
||||
- 渲染进程的发送失败会被丢弃,导出器没有持久化待发队列或数据仓库确认。
|
||||
- 原生启动事件等待 Host 认证完成;若进程在 Host 就绪前失败,则无法上报启动事件。
|
||||
|
||||
### 开发备注
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-product-analytics",
|
||||
"description": "Desktop product event collection and authenticated Host reporting",
|
||||
"version": "0.1.7-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/product-analytics"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/events.d.ts"
|
||||
}
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:*",
|
||||
"@deepseek-ai/dsh-compaction": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@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-settings": "workspace:*",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
"@deepseek-ai/dsh-otel": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** 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'
|
||||
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'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
productAnalytics: DesktopAnalytics
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopAnalytics extends Service {
|
||||
private collecting = false
|
||||
// RPC dispatch stays in the provider context when Cordis traces a consumer call.
|
||||
private readonly submit: (event: ProductEvent) => Promise<void>
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'productAnalytics')
|
||||
this.submit = async (event) => {
|
||||
try { await ctx.remote.productAnalytics.report(event) } catch (_error) {
|
||||
// Missing or disconnected Remote services must not interrupt the interaction.
|
||||
}
|
||||
}
|
||||
if (!('dshDesktop' in globalThis)) return
|
||||
const stream = ctx.remote.$stream<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 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], timestamp = Date.now()): void {
|
||||
if (!this.enabled) return
|
||||
const event = { eventName: name, attributes, timestamp } as ProductEvent
|
||||
void this.submit(event)
|
||||
}
|
||||
}
|
||||
|
||||
/** Analytics consumes authenticated RPC and its reconnecting policy stream. */
|
||||
export const inject = ['remote', 'remote.productAnalytics']
|
||||
|
||||
/** @param ctx - browser application context. */
|
||||
export function apply(ctx: Context): void { ctx.plugin(DesktopAnalytics) }
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Desktop analytics fields selected by product event owners. */
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
|
||||
/** Event names and their selected product attributes. */
|
||||
export interface ProductEventMap {
|
||||
desktop_app_launch: Record<string, never>
|
||||
auth_page_view: Record<string, never>
|
||||
auth_page_click: { button_name: 'sign_in' | 'api-key' }
|
||||
api_key_save_click: Record<string, never>
|
||||
onboarding_page_view: { page_name: OnboardingPage }
|
||||
onboarding_page_click: {
|
||||
page_name: OnboardingPage
|
||||
button_name: 'next' | 'back' | 'skip' | 'charge' | 'later' | 'continue'
|
||||
selected_content?: 'office' | 'code' | 'code_office' | 'focus_result' | 'key_detail' | 'full_process'
|
||||
}
|
||||
onboarding_popup_view: { popup_name: 'skip_charge' | 'skip_setting' }
|
||||
onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' | 'close' }
|
||||
desktop_upgrade_click: Record<string, never>
|
||||
desktop_upgrade_download_result: { is_success: boolean; error_reason?: string }
|
||||
desktop_upgrade_install_restart_click: Record<string, never>
|
||||
send_button_click: {
|
||||
session_id?: SessionId
|
||||
model_name?: string
|
||||
thinking_effort?: string
|
||||
run_mode: 'plan' | 'goal' | 'default'
|
||||
msg_type: 'default' | 'steer' | 'queue'
|
||||
}
|
||||
model_switch: { session_id?: SessionId; switch_from: string; switch_to: string }
|
||||
thinking_level_switch: { session_id?: SessionId; switch_from: string; switch_to: string; model_name: string }
|
||||
context_compression: { session_id: SessionId; trigger_type: 'auto' | 'manual' }
|
||||
branch_session_click: {
|
||||
session_id: SessionId
|
||||
parent_session_id: SessionId
|
||||
parent_message_id?: MessageId
|
||||
click_position: 'footer' | 'sidebar'
|
||||
}
|
||||
sidebar_menu_click: { menu_name: 'plugin' | 'cron' }
|
||||
plugin_toggle: { plugin_name: string; plugin_type: 'plugin' | 'bundle'; is_enabled: boolean; is_builtin: boolean }
|
||||
plugin_add_button_click: Record<string, never>
|
||||
plugin_install_click: { input_value: string }
|
||||
install_plugin_result: {
|
||||
input_value: string
|
||||
is_success: boolean
|
||||
error_reason?: string
|
||||
duration: number
|
||||
plugin_name?: string
|
||||
}
|
||||
confirm_uninstall_plugin: { plugin_name: string }
|
||||
}
|
||||
|
||||
/** Public page names independent of internal onboarding state names. */
|
||||
export type OnboardingPage = 'onboarding_welcome' | 'onboarding_recharge' | 'onboarding_use_case' | 'onboarding_process'
|
||||
|
||||
/** Correlated event name and attributes for the authenticated analytics RPC. */
|
||||
export type ProductEvent = {
|
||||
[K in keyof ProductEventMap]: { eventName: K; attributes: ProductEventMap[K]; timestamp: number }
|
||||
}[keyof ProductEventMap]
|
||||
|
||||
/** Callback passed into presentation components; omitted when collection is disabled. */
|
||||
export type TrackProductEvent = <K extends keyof ProductEventMap>(name: K, attributes: ProductEventMap[K]) => void
|
||||
@@ -0,0 +1,101 @@
|
||||
/** Desktop-only analytics RPC and live compaction collection. */
|
||||
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-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 {
|
||||
/** Live application collection policy; ordinary Web does not mount this service. */
|
||||
enabled: Volatile<boolean>
|
||||
/** Running Desktop release, absent when unavailable. */
|
||||
appVersion?: string
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
productAnalytics: ProductAnalytics
|
||||
}
|
||||
}
|
||||
|
||||
/** Authenticated event intake; disabled instances do not inspect identity or accept new events. */
|
||||
export default class ProductAnalytics extends TypertRemoteService {
|
||||
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')
|
||||
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(),
|
||||
attributes: { session_id: session.id, trigger_type: event.data.turn === null ? 'manual' : 'auto' } })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the collection policy.
|
||||
* @returns whether this Host currently accepts Desktop analytics.
|
||||
*/
|
||||
@Remote
|
||||
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 update = Promise.withResolvers<void>()
|
||||
const changed = (): void => { update.resolve() }
|
||||
this.listeners.add(changed)
|
||||
signal.addEventListener('abort', changed, { once: true })
|
||||
try {
|
||||
while (this.active && !signal.aborted) {
|
||||
yield this.enabled()
|
||||
await update.promise
|
||||
update = Promise.withResolvers<void>()
|
||||
}
|
||||
} finally {
|
||||
this.listeners.delete(changed)
|
||||
signal.removeEventListener('abort', changed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit selected Desktop fields; missing identity is omitted and never generated.
|
||||
* @param event - typed product event without message contents or credentials.
|
||||
* @returns after local submission; no delivery or warehouse acknowledgement.
|
||||
*/
|
||||
@Remote
|
||||
async report(event: ProductEvent): Promise<void> {
|
||||
if (!this.enabled()) return
|
||||
try {
|
||||
const identity = await this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined)
|
||||
if (!this.enabled()) return
|
||||
this.ctx.productTelemetry.emit({
|
||||
...event, body: event.eventName,
|
||||
attributes: {
|
||||
...event.attributes,
|
||||
...identity?.deviceId === undefined ? {} : { device_id: identity.deviceId },
|
||||
...identity?.userId === undefined ? {} : { user_id: identity.userId },
|
||||
...this.config.appVersion === undefined ? {} : { app_version: this.config.appVersion },
|
||||
...identity === undefined ? {} : { os_version: identity.osVersion },
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('Product analytics submission failed', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import * as Analytics from '../src/client/index.ts'
|
||||
|
||||
const cleanup: (() => Promise<void>)[] = []
|
||||
afterEach(async () => {
|
||||
for (const dispose of cleanup.splice(0).reverse()) await dispose()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
async function setup(enabled = true) {
|
||||
const ctx = new Context()
|
||||
cleanup.push(() => ctx.fiber.dispose())
|
||||
const report = vi.fn().mockResolvedValue({ ok: true, value: undefined })
|
||||
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?.() },
|
||||
}
|
||||
const remote = {
|
||||
productAnalytics: { report, watchPolicy: vi.fn() },
|
||||
$stream: (value: RemoteStreamOptions<boolean>) => { options = value; return stream },
|
||||
}
|
||||
class TracedRemote extends Service {
|
||||
constructor(ctx: Context) { super(ctx, 'remote') }
|
||||
$stream = remote.$stream
|
||||
}
|
||||
await ctx.plugin(TracedRemote)
|
||||
await ctx.plugin({ apply(child: Context) { child.provide('remote.productAnalytics', remote.productAnalytics as never) } })
|
||||
await ctx.plugin(Analytics)
|
||||
if ('dshDesktop' in globalThis) await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(enabled) })
|
||||
return { ctx, report, options: () => options!,
|
||||
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 () => {
|
||||
const b = await setup()
|
||||
expect(b.ctx.productAnalytics.enabled).toBe(false)
|
||||
b.ctx.productAnalytics.track('desktop_app_launch', {})
|
||||
expect(b.report).not.toHaveBeenCalled()
|
||||
})
|
||||
it('drops disabled events and has no backfill when enabled', async () => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const b = await setup(false)
|
||||
b.ctx.productAnalytics.track('auth_page_view', {})
|
||||
expect(b.report).not.toHaveBeenCalled()
|
||||
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', {})
|
||||
const b = await setup()
|
||||
b.report.mockRejectedValueOnce(new Error('offline'))
|
||||
b.ctx.productAnalytics.track('auth_page_view', {})
|
||||
await Promise.resolve()
|
||||
expect(b.report).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not interrupt an action when Remote access fails synchronously', async () => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
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()
|
||||
})
|
||||
|
||||
it('reports from a consumer without granting it the analytics Remote namespace', async () => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const b = await setup()
|
||||
let click!: () => void
|
||||
await b.ctx.plugin({
|
||||
inject: ['remote'],
|
||||
apply(ctx: Context) {
|
||||
click = () => {
|
||||
expect(ctx.get('productAnalytics')?.enabled).toBe(true)
|
||||
ctx.get('productAnalytics')?.track('sidebar_menu_click', { menu_name: 'plugin' })
|
||||
}
|
||||
},
|
||||
})
|
||||
click()
|
||||
expect(b.report).toHaveBeenCalledExactlyOnceWith({
|
||||
eventName: 'sidebar_menu_click', timestamp: expect.any(Number) as number, attributes: { menu_name: 'plugin' },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import OTel from '@deepseek-ai/dsh-otel'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createServer } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
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 Analytics from '../src/index.ts'
|
||||
|
||||
const cleanup: (() => Promise<void>)[] = []
|
||||
afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose() })
|
||||
|
||||
async function setup(enabled: boolean) {
|
||||
const ctx = new Context()
|
||||
cleanup.push(() => ctx.fiber.dispose())
|
||||
const getDeviceIdentity = vi.fn().mockResolvedValue({ deviceId: 'login-device', userId: 'user-1', osVersion: 'fixture-os', ignored: 'private-field' })
|
||||
const emit = vi.fn<(record: ProductTelemetryRecord) => void>()
|
||||
ctx.provide('deepseekAccount', { getDeviceIdentity } as never)
|
||||
ctx.provide('webServer', {} as never)
|
||||
ctx.provide('productTelemetry', { emit } as never)
|
||||
const fiber = await ctx.plugin(Analytics, { enabled, appVersion: 'test-version' })
|
||||
return { ctx, fiber, getDeviceIdentity, emit }
|
||||
}
|
||||
|
||||
it('disabled collection does not read identity or submit an event', async () => {
|
||||
const b = await setup(false)
|
||||
expect(b.ctx.productAnalytics.enabled()).toBe(false)
|
||||
await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} })
|
||||
expect(b.getDeviceIdentity).not.toHaveBeenCalled()
|
||||
expect(b.emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses login identity and copies only approved common fields', async () => {
|
||||
const b = await setup(true)
|
||||
await b.ctx.productAnalytics.report({ eventName: 'auth_page_click', timestamp: 100, attributes: { button_name: 'sign_in' } })
|
||||
expect(b.getDeviceIdentity).toHaveBeenCalledWith()
|
||||
expect(b.emit).toHaveBeenCalledExactlyOnceWith({
|
||||
eventName: 'auth_page_click', body: 'auth_page_click', timestamp: 100,
|
||||
attributes: { button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version', os_version: expect.any(String) as string },
|
||||
})
|
||||
expect(JSON.stringify(b.emit.mock.calls)).not.toContain('private-')
|
||||
})
|
||||
|
||||
it('missing identity does not discard an otherwise valid event', async () => {
|
||||
const b = await setup(true)
|
||||
b.getDeviceIdentity.mockRejectedValueOnce(new Error('credential store unavailable'))
|
||||
await b.ctx.productAnalytics.report({ eventName: 'plugin_add_button_click', timestamp: 100, attributes: {} })
|
||||
expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ app_version: 'test-version' })
|
||||
})
|
||||
|
||||
it('unload suppresses an identity lookup that settles after disposal', async () => {
|
||||
const b = await setup(true)
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
b.getDeviceIdentity.mockReturnValueOnce(pending.promise)
|
||||
const reporting = b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} })
|
||||
await b.fiber.dispose()
|
||||
pending.resolve(undefined)
|
||||
await reporting
|
||||
expect(b.emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes the selected event through the real exporter to an isolated collector', async () => {
|
||||
const captures: string[] = []
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on('end', () => { captures.push(Buffer.concat(chunks).toString()); res.end('{}') })
|
||||
})
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
cleanup.push(async () => { const done = once(server, 'close'); server.close(); server.closeAllConnections(); await done })
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('missing collector port')
|
||||
const ctx = new Context()
|
||||
cleanup.push(() => ctx.fiber.dispose())
|
||||
ctx.provide('credentials', { readRecord: async () => undefined } as never)
|
||||
ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never)
|
||||
ctx.provide('webServer', {} as never)
|
||||
await ctx.plugin(OTel)
|
||||
const exporter = await ctx.plugin(ProductTelemetry, TelemetryConfig({ endpoint: `http://127.0.0.1:${address.port}/v1/logs`, serviceName: 'test', serviceVersion: '1', compression: 'none' }))
|
||||
await ctx.plugin(Analytics, { enabled: true })
|
||||
await ctx.productAnalytics.report({ eventName: 'api_key_save_click', timestamp: 1_800_000_000_000, attributes: {} })
|
||||
await exporter.dispose()
|
||||
expect(captures).toHaveLength(1)
|
||||
expect(JSON.parse(captures[0]!)).toMatchObject({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ eventName: 'api_key_save_click', timeUnixNano: '1800000000000000000' }] }] }] })
|
||||
expect(captures[0]).not.toContain('device_id')
|
||||
expect(captures[0]).not.toContain('app_version')
|
||||
})
|
||||
|
||||
it.each([true, false])('collects live manual and automatic compaction only when enabled: %s', async (enabled) => {
|
||||
const b = await setup(enabled)
|
||||
const session = Session.create(SessionId('analytics-compaction'))
|
||||
b.ctx.emit('session/event', session, { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } })
|
||||
for (const turn of [null, 1]) {
|
||||
b.ctx.emit('session/event', session, { type: 'compaction/start', seq: SessionSeq(1), time: 1,
|
||||
data: { compactionId: CompactionId('analytics-compaction'), turn } })
|
||||
}
|
||||
if (enabled) await vi.waitFor(() => { expect(b.emit).toHaveBeenCalledTimes(2) })
|
||||
expect(b.emit.mock.calls.map(([event]) => event.attributes?.trigger_type)).toEqual(enabled ? ['manual', 'auto'] : [])
|
||||
expect(b.getDeviceIdentity).toHaveBeenCalledTimes(enabled ? 2 : 0)
|
||||
})
|
||||
|
||||
it('omits an unavailable account and isolates exporter submission failure', async () => {
|
||||
const b = await setup(true)
|
||||
b.getDeviceIdentity.mockRejectedValueOnce(new Error('account unavailable'))
|
||||
await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
|
||||
expect(b.emit.mock.calls[0]![0].attributes).not.toHaveProperty('user_id')
|
||||
b.emit.mockImplementationOnce(() => { throw new Error('exporter unavailable') })
|
||||
await expect(b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 2, attributes: {} })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
it('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()
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts",
|
||||
"src/events.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../api/gateway/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/index.ts",
|
||||
"src/events.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../host/product-telemetry-otel"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/deepseek-account"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../compaction/compaction"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-product-analytics', ['lib/types/index.js'], { hostPhase: true })
|
||||
@@ -6,8 +6,8 @@
|
||||
en: d88f75fc0178ba53
|
||||
zh: 52bc7ae9551ab1cf
|
||||
/deepseek-ai-dsh-client-ui-chat:
|
||||
en: a0be18d7053df6b0
|
||||
zh: 28c76e2cc32b7494
|
||||
en: 02bd72c12ada3218
|
||||
zh: 331b3a38b3ddfc6e
|
||||
/deepseek-ai-dsh-client-ui-chat/summary:
|
||||
en: fba5b2fb8080e2dd
|
||||
zh: 32c337123b7dfbcb
|
||||
|
||||
@@ -6,6 +6,8 @@ kind: "package-reference"
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
|
||||
|
||||
## Summary
|
||||
|
||||
Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and process visibility without hiding final answers; Verbose keeps completed-turn process rows visible. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests.
|
||||
|
||||
@@ -6,6 +6,8 @@ kind: "package-reference"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
|
||||
|
||||
## 概述
|
||||
|
||||
使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏最终答案;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*"
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Register the Chat Conversation target, renderers, stats, and details surface. */
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
@@ -245,7 +246,11 @@ export function apply(ctx: Context): void {
|
||||
read: () => chatScrollPositions.get(sessionId) ?? null,
|
||||
},
|
||||
forkAt: (seq) => {
|
||||
ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
|
||||
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' })
|
||||
} })
|
||||
.then((childId) => { ctx.uiWorkspace.openSession(childId) })
|
||||
.catch(() => {
|
||||
// Fork or child-title failure leaves the source view unchanged.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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'
|
||||
@@ -170,18 +171,36 @@ describe('Chat inject API', () => {
|
||||
void injected.loadThrough(SessionSeq(42))
|
||||
expect(b.session.loadThrough).toHaveBeenCalledWith(42)
|
||||
|
||||
const track = vi.fn()
|
||||
b.runtime.ctx.provide('productAnalytics', { track } as never)
|
||||
const forkCreated = vi.spyOn(b.runtime.sessions, 'fork').mockImplementation(async (input) => { input.onCreated?.(ROOT); return ROOT })
|
||||
injected.forkAt(17)
|
||||
expect(track).toHaveBeenCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, click_position: 'footer' })
|
||||
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(() => {
|
||||
expect(b.openSession).toHaveBeenCalledWith(ROOT)
|
||||
})
|
||||
expect(b.runtime.sessions.calls).toContainEqual({
|
||||
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
|
||||
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void }],
|
||||
})
|
||||
|
||||
const fork = vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork failed'))
|
||||
injected.forkAt(18)
|
||||
await vi.waitFor(() => {
|
||||
expect(fork).toHaveBeenCalledWith({ sessionId: ROOT, atSeq: 18, increaseTitle: true })
|
||||
expect(fork).toHaveBeenCalledWith({
|
||||
sessionId: ROOT, atSeq: 18, increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void,
|
||||
})
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -113,6 +113,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../ui-input-trigger"
|
||||
},
|
||||
{
|
||||
"path": "../product-analytics/tsconfig.client.json"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 0ca51f37788dcbdb
|
||||
zh: 14d73c6bc8d4e17f
|
||||
/deepseek-ai-dsh-client-ui-conversation:
|
||||
en: 71d52109076d859d
|
||||
zh: 8e63171fbd9bde48
|
||||
en: 77ecbd074ef3a3ce
|
||||
zh: 2c0e2b720e3b201e
|
||||
/deepseek-ai-dsh-client-ui-conversation/summary:
|
||||
en: bac0bf0d7330f3b1
|
||||
zh: 84a895f4b26d8490
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-reference"
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
|
||||
|
||||
## Summary
|
||||
|
||||
`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller `SessionEventLikeEntry` feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
|
||||
@@ -74,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.
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-reference"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
|
||||
|
||||
## 概述
|
||||
|
||||
`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。
|
||||
@@ -74,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 恢复失败草稿,保留用户随后输入的文字。
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
"@lexical/history": "^0.49.0",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Registers the target-neutral Conversation assembly, shell, input, and docks. */
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export interface ComposerKeyboard {
|
||||
/** The shell-owned Lexical editor the composer binds its contenteditable to. */
|
||||
readonly editor: LexicalEditor
|
||||
/** Submit with an explicit delivery mode resolved by the submission policy (Enter gestures and the primary Send button). */
|
||||
submit(mode: InputSubmitMode): void
|
||||
submit(mode: InputSubmitMode, source?: 'click' | 'enter'): void
|
||||
/**
|
||||
* Steer every still-pending queued message into the running turn (the
|
||||
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
|
||||
|
||||
@@ -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 =
|
||||
@@ -186,7 +186,7 @@ export interface SessionInput extends InputTarget {
|
||||
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
|
||||
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
|
||||
*/
|
||||
submit(mode?: InputSubmitMode): void
|
||||
submit(mode?: InputSubmitMode, source?: 'click' | 'enter'): void
|
||||
/**
|
||||
* Surface a notice outside the machine's own effect stream: detached
|
||||
* command results and business notifications render through here.
|
||||
@@ -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. */
|
||||
|
||||
@@ -134,7 +134,7 @@ export function installDraftKeymap(
|
||||
g.running,
|
||||
accelerated ? 'accelerated' : 'enter',
|
||||
g.steeringAvailable,
|
||||
))
|
||||
), 'enter')
|
||||
},
|
||||
intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) },
|
||||
pasteText: (text) => {
|
||||
|
||||
@@ -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,6 +41,10 @@ export interface PopupDismissFace {
|
||||
export interface SessionInputDeps {
|
||||
/** Session-scope ctx handed to claim.submit transactions. */
|
||||
actx: Context
|
||||
/** 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). */
|
||||
@@ -290,7 +294,16 @@ export class SessionInputShell implements SessionInput {
|
||||
* (adjudicating/submitting) force-closes the transient layers: the popup
|
||||
* dismisses and the menu tracks frozen.
|
||||
*/
|
||||
submit(mode: InputSubmitMode = 'queue'): void {
|
||||
submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void {
|
||||
if (this.disposed) return
|
||||
const timestamp = Date.now()
|
||||
let state: MessageSubmissionState | undefined
|
||||
if (this.snapshot.phase === 'plain' && (this.snapshot.draft.trim() !== '' || this.attachmentIds.length > 0)) {
|
||||
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]
|
||||
@@ -299,6 +312,7 @@ export class SessionInputShell implements SessionInput {
|
||||
const flight = this.attachmentFlightSeq
|
||||
this.attachmentFlights.set(flight, { controller, attachmentIds })
|
||||
this.commitSend(attachmentIds)
|
||||
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
|
||||
@@ -321,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 }))
|
||||
this.dispatchRun({ type: 'enter', mode, draft: this.projection.clipboardText, submission })
|
||||
const phase = this.snapshot.phase
|
||||
if (phase === 'adjudicating' || phase === 'submitting') {
|
||||
this.deps.popup?.()?.dismiss()
|
||||
@@ -572,7 +586,8 @@ export class SessionInputShell implements SessionInput {
|
||||
/** Dispatch + execute, refreshing the claim decoration when the styled token flips. */
|
||||
private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
|
||||
const beforeToken = this.activeClaimToken()
|
||||
this.run(this.core.dispatch(ev))
|
||||
const effects = this.core.dispatch(ev)
|
||||
this.run(effects)
|
||||
if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration()
|
||||
}
|
||||
|
||||
@@ -633,6 +648,7 @@ export class SessionInputShell implements SessionInput {
|
||||
draft: string,
|
||||
mode: InputSubmitMode,
|
||||
): void {
|
||||
this.notifySubmission(attempt.submission)
|
||||
const attachmentIds = [...this.attachmentIds]
|
||||
this.attachmentIds = []
|
||||
const occurrences = this.projection.occurrences
|
||||
@@ -677,6 +693,11 @@ export class SessionInputShell implements SessionInput {
|
||||
)
|
||||
}
|
||||
|
||||
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. */
|
||||
private settleSink(
|
||||
attempt: SubmitAttempt,
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* listeners on each Session context and owns the default-sink choreography: every session is a
|
||||
* real host entity, so the sink is one unconditional prompt path.
|
||||
*/
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/types'
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/types'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ISessions, SessionBinding, SessionFace,
|
||||
@@ -23,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 {
|
||||
@@ -90,6 +95,23 @@ export class InputHub implements SessionInputResolver {
|
||||
const { session, ctx: actx } = binding
|
||||
const shell = new SessionInputShell({
|
||||
actx,
|
||||
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
|
||||
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,21 @@
|
||||
/** 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, 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,
|
||||
msg_type: state.running ? mode : 'default',
|
||||
}, timestamp)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ export const InputBar = memo(function InputBar({
|
||||
}
|
||||
if (keyboard === undefined) return // absent machine: the button is disabled
|
||||
/* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */
|
||||
if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode)
|
||||
if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode, 'click')
|
||||
}
|
||||
|
||||
// Claim ghost hint: rendered by CSS as generated content after the last
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InputTriggerController, SubmitOutcome } from '../src/client/contract/input.ts'
|
||||
import type { InputTriggerController, SubmitOutcome, PickOutcome } from '../src/client/contract/input.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import type { DraftAttachmentId } from '../src/client/contract/input.ts'
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -283,3 +289,114 @@ describe('submit transaction hardening', () => {
|
||||
expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('captures click and Enter submission intent before async admission, excluding empty submits', async () => {
|
||||
const report = vi.fn()
|
||||
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(submissionState).not.toHaveBeenCalled()
|
||||
shell.setDraft('first')
|
||||
shell.submit('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(report).toHaveBeenLastCalledWith(expect.objectContaining({ mode: 'queue', source: 'enter' }))
|
||||
expect(report).toHaveBeenCalledTimes(2)
|
||||
} finally { shell.dispose() }
|
||||
})
|
||||
|
||||
|
||||
it.each(['capture', 'report'])('analytics %s failure does not interrupt a message', async (stage) => {
|
||||
const sink = vi.fn(async (): Promise<SubmitOutcome> => ({ kind: 'success' }))
|
||||
const fail = () => { throw new Error('analytics unavailable') }
|
||||
const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments,
|
||||
...stage === 'capture' ? { submissionState: fail } : { messageSubmitted: fail } })
|
||||
try {
|
||||
shell.setDraft('message')
|
||||
expect(() => { shell.submit('queue', 'click') }).not.toThrow()
|
||||
expect(sink).toHaveBeenCalledTimes(1)
|
||||
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
|
||||
} finally { shell.dispose() }
|
||||
})
|
||||
|
||||
|
||||
it.each(['handled', 'claim', 'message'] as const)('counts only a message after asynchronous slash adjudication: %s', async (kind) => {
|
||||
const pending = Promise.withResolvers<PickOutcome>()
|
||||
const report = vi.fn()
|
||||
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 = {
|
||||
launcher: { getSnapshot: () => null, subscribe: () => () => {} },
|
||||
lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
|
||||
track: () => {}, arbitrate: () => 'pass', onSpace: () => false,
|
||||
serializeReference: async () => '', openReference: () => false, toggleSource: () => {},
|
||||
adjudicate: () => pending.promise,
|
||||
}
|
||||
const shell = new SessionInputShell({ actx: {} as Context, inputTriggers: () => inputTriggers,
|
||||
submissionState, messageSubmitted: report, defaultSink: sink, commandAttachments })
|
||||
try {
|
||||
shell.setDraft('/compact')
|
||||
shell.submit('queue', 'enter')
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
pending.resolve(kind === 'handled' ? 'handled' : kind === 'claim' ? { claim: { name: 'compact', token: '/compact', submit: command } } : undefined)
|
||||
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
|
||||
expect(report).toHaveBeenCalledTimes(kind === 'message' ? 1 : 0)
|
||||
expect(sink).toHaveBeenCalledTimes(kind === 'message' ? 1 : 0)
|
||||
expect(submissionState).toHaveBeenCalledOnce()
|
||||
shell.setDraft('programmatic')
|
||||
shell.actions.submit()
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -876,3 +876,39 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('captures run mode and queue intent at submission before later projection changes', async () => {
|
||||
const b = await bench()
|
||||
const track = vi.fn()
|
||||
b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
|
||||
try {
|
||||
await b.runtime.sessions.setProjection('s1', 'plan', { active: true, pending: false })
|
||||
await b.runtime.sessions.setProjection('s1', 'goal', { goal: { phase: 'active' } })
|
||||
await b.runtime.sessions.setProjection('s1', 'modelSelection', { next: { provider: 'fixture', model: 'model', reasoningEffort: 'high' }, lastUsed: null })
|
||||
await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = true; state.blank = false })
|
||||
b.shell.setDraft('private prompt')
|
||||
b.shell.submit('queue', 'click')
|
||||
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
|
||||
session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', msg_type: 'queue',
|
||||
}, expect.any(Number))
|
||||
await b.runtime.sessions.setProjection('s1', 'plan', { active: false, pending: false })
|
||||
expect(track).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.stringify(track.mock.calls)).not.toContain('private prompt')
|
||||
} finally { await b.runtime.dispose() }
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ running: false, mode: 'queue', expected: 'default' },
|
||||
{ running: true, mode: 'steer', expected: 'steer' },
|
||||
] as const)('reports msg_type=$expected without gesture fields', async ({ running, mode, expected }) => {
|
||||
const b = await bench()
|
||||
const track = vi.fn()
|
||||
b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
|
||||
try {
|
||||
await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = running; state.blank = true })
|
||||
b.shell.setDraft('message')
|
||||
b.shell.submit(mode, 'enter')
|
||||
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { run_mode: 'default', msg_type: expected }, expect.any(Number))
|
||||
} finally { await b.runtime.dispose() }
|
||||
})
|
||||
@@ -101,6 +101,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../shortcuts/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../product-analytics/tsconfig.client.json"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: f39813648aeef5a0
|
||||
zh: eea25094e8bfc031
|
||||
/deepseek-ai-dsh-client-ui-model-selection:
|
||||
en: 5e007d579a0e22f0
|
||||
zh: edfff0d021712077
|
||||
en: 4cb6feb12e46f3a0
|
||||
zh: eea39dd76fe1fa8b
|
||||
/deepseek-ai-dsh-client-ui-model-selection/summary:
|
||||
en: 2fb361ef704528fb
|
||||
zh: 2537d2afb4d2cb45
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-reference"
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
|
||||
|
||||
## Summary
|
||||
|
||||
The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If the selected model is unavailable, the composer stays disabled until the user selects an available model or that exact model becomes available again.
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-reference"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
|
||||
|
||||
## 概述
|
||||
|
||||
Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
|
||||
"clsx": "^2.1.1"
|
||||
"clsx": "^2.1.1",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Session's durable selection projection, then submit through the same
|
||||
* selectModel call. A switch made in either entry updates this shared state.
|
||||
*/
|
||||
import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type {
|
||||
ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
@@ -52,6 +53,8 @@ export class ModelDirectory {
|
||||
* @param available - whether this session may use Agent-bound model RPCs.
|
||||
* @param catalog - Host-generation catalog shared by every Session.
|
||||
* @param projected - durable model selection projected from Session history.
|
||||
* @param isBlank - whether this Session has no first message yet.
|
||||
* @param track - desktop-only callback after a successful user selection.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessions: Pick<TypertClientRemote['session'], 'selectModel'>,
|
||||
@@ -59,6 +62,8 @@ export class ModelDirectory {
|
||||
private readonly available: () => boolean,
|
||||
private readonly catalog: ModelCatalogDirectory,
|
||||
private readonly projected: ObservableSnapshot<unknown>,
|
||||
private readonly isBlank: () => boolean,
|
||||
private readonly track?: TrackProductEvent,
|
||||
) {
|
||||
this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() })
|
||||
this.unsubscribeSelection = projected.subscribe(() => { this.syncInputs() })
|
||||
@@ -85,6 +90,9 @@ export class ModelDirectory {
|
||||
*/
|
||||
async select(selection: ModelSelection): Promise<RemoteResult<void>> {
|
||||
this.assertAvailable()
|
||||
const previous = this.store.getSnapshot().current
|
||||
const previousEffort = previous?.reasoningEffort ?? (previous === null ? undefined : this.catalog.reasoningFor(previous)?.defaultEffort)
|
||||
const nextEffort = selection.reasoningEffort ?? this.catalog.reasoningFor(selection)?.defaultEffort
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'selecting'; s.pending = selection; s.error = null })
|
||||
const result = await this.sessions.selectModel({
|
||||
@@ -106,6 +114,14 @@ export class ModelDirectory {
|
||||
})
|
||||
return result
|
||||
}
|
||||
if (previous !== null) {
|
||||
const from = `${previous.provider}/${previous.model}`
|
||||
const to = `${selection.provider}/${selection.model}`
|
||||
if (from !== to) this.track?.('model_switch', { ...this.isBlank() ? {} : { session_id: this.sessionId }, switch_from: from, switch_to: to })
|
||||
if (from === to && previousEffort !== nextEffort) this.track?.('thinking_level_switch', {
|
||||
...this.isBlank() ? {} : { session_id: this.sessionId }, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default',
|
||||
})
|
||||
}
|
||||
this.store.update((s) => { s.status = 'ready'; s.pending = null; s.error = null })
|
||||
this.syncInputs()
|
||||
return { ok: true, value: undefined }
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* strings, and it models global+shadow named registries — this is a
|
||||
* per-session singleton with no global layer to merge.
|
||||
*/
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
@@ -77,6 +78,8 @@ export class ModelDirectoryResolver extends Service {
|
||||
() => sessions.subagentAddress(sessionId) === undefined,
|
||||
this.catalog,
|
||||
binding.session.projections.faceOf('modelSelection'),
|
||||
() => binding.session.getSnapshot().blank,
|
||||
(name, attributes) => this.ctx.get('productAnalytics')?.track(name, attributes),
|
||||
)
|
||||
live.directories.set(binding, directory)
|
||||
actx.effect(() => () => {
|
||||
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user