Merge latest master documentation into Agent Team delivery

This commit is contained in:
Dudu-0223 committed 2026-09-27 06:45:46 +08:00
commit a98efc7115
173 files changed
+2359 -167

No files matched your search

+5 -1
View File
@@ -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(root, 'package.json'), '{"type":"module"}')
writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') 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', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), ` writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
@@ -9,6 +9,7 @@ const state: AccountView = {
} }
class Account extends DeepSeekAccount { class Account extends DeepSeekAccount {
getDeviceIdentity = async () => ({ osVersion: 'test-os' })
fail = false fail = false
ended = Promise.withResolvers<undefined>() ended = Promise.withResolvers<undefined>()
reading = Promise.withResolvers<undefined>() reading = Promise.withResolvers<undefined>()
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with: # After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md # pnpm run verify-translation-pairing --write apps/desktop/README.md
/deepseek-harness-desktop: /deepseek-harness-desktop:
en: 45561fc0dd3a1b20 en: b8e4f357cf330721
zh: f4b03355d4bd0693 zh: cdaaf35a061f41b1
/deepseek-harness-desktop/closing-the-window-and-quitting: /deepseek-harness-desktop/closing-the-window-and-quitting:
en: c323e02e3f0305fe en: c323e02e3f0305fe
zh: 3c730db632136443 zh: 3c730db632136443
+2
View File
@@ -2,6 +2,8 @@
English | [中文](README.zh.md) 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 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. 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
View File
@@ -2,6 +2,8 @@
[English](README.md) | 中文 [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 覆盖。 桌面应用是完整 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。 应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
+2 -1
View File
@@ -75,6 +75,7 @@
"vite": "^6.0.0", "vite": "^6.0.0",
"@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "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:*"
} }
} }
+16 -5
View File
@@ -67,9 +67,13 @@ export interface DesktopAccountBackend {
* @param listener - state recipient. * @param listener - state recipient.
* @param failed - stream failure recipient. * @param failed - stream failure recipient.
* @param expired - live credential-expiry recipient. * @param expired - live credential-expiry recipient.
* @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false.
* @returns stream disposer. * @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'), state: () => call('getState'),
start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }), cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }),
watch(listener, failed, expired) { watch(listener, failed, expired, onAnalyticsEnabledChanged) {
let closed = false let closed = false
let socket: WebSocket | undefined let socket: WebSocket | undefined
let retry: ReturnType<typeof setTimeout> | undefined let retry: ReturnType<typeof setTimeout> | undefined
const connect = (): void => { const connect = (): void => {
const streamId = randomUUID() const streamId = randomUUID()
const expiryStreamId = randomUUID() const expiryStreamId = randomUUID()
const analyticsPolicyStreamId = randomUUID()
void cookies().then((cookie) => { void cookies().then((cookie) => {
if (closed) return if (closed) return
const url = new URL(REMOTE_STREAM_MUX_PATH, origin) const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
socket.on('open', () => { 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: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', 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 { try {
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) 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 === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
if (frame.type === 'item') listener(accountView(frame.value)) if (frame.type === 'item') listener(accountView(frame.value))
@@ -113,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
} catch { socket?.close() } } catch { socket?.close() }
}) })
socket.on('error', () => { socket?.close() }) socket.on('error', () => { socket?.close() })
socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
}).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
} }
connect() connect()
return () => { closed = true; clearTimeout(retry); socket?.close() } return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() }
}, },
} }
} }
+9 -5
View File
@@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
const [expiryNotice, setExpiryNotice] = useState(false) const [expiryNotice, setExpiryNotice] = useState(false)
const [page, setPage] = useState<Page>('entry') const [page, setPage] = useState<Page>('entry')
const pageRef = useRef<Page>('entry') const pageRef = useRef<Page>('entry')
const visiblePage = useRef<Page>('entry')
const [attempt, setAttempt] = useState<AccountView['attempt']>(null) const [attempt, setAttempt] = useState<AccountView['attempt']>(null)
const attemptRef = useRef<AccountView['attempt']>(null) const attemptRef = useRef<AccountView['attempt']>(null)
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
@@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) {
}, [api, m.welcomeTitle]) }, [api, m.welcomeTitle])
useEffect(() => { useEffect(() => {
if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {})
visiblePage.current = page
if (page === 'key') input.current?.focus() if (page === 'key') input.current?.focus()
else if (page === 'entry' && focusEntry.current) { else if (page === 'entry' && focusEntry.current) {
focusEntry.current = false focusEntry.current = false
keyButton.current?.focus() keyButton.current?.focus()
} }
}, [page]) }, [page, api])
useEffect(() => { useEffect(() => {
if (copyState !== 'copied' && copyState !== 'failed') return if (copyState !== 'copied' && copyState !== 'failed') return
@@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
async function saveKey(event: FormEvent) { async function saveKey(event: FormEvent) {
event.preventDefault() event.preventDefault()
if (busyRef.current) return if (busyRef.current) return
void api.analytics?.('api_key_save_click', {})
const value = draft.trim() const value = draft.trim()
if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) 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])) { || ((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}> <button id="auth-loading" className="primary" type="button" hidden={failed} disabled aria-label={m.welcomeAuthExchanging}>
<StateDot state="ongoing" size={16} className="welcome-loading" /> <StateDot state="ongoing" size={16} className="welcome-loading" />
</button> </button>
<button id="auth-retry" className="primary" type="button" hidden={!failed} onClick={() => { void start() }}>{m.welcomeAuthRetry}</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={() => { navigate('key') }}>{m.welcomeApiKey}</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} <button id="auth-cancel" className="secondary" type="button" hidden={failed}
disabled={cancelling || phase === 'committing' || phase === 'succeeded' || (phase === 'initializing' && !attempt?.id)} disabled={cancelling || phase === 'committing' || phase === 'succeeded' || (phase === 'initializing' && !attempt?.id)}
onClick={() => { void cancel() }}>{m.welcomeAuthCancel}</button> onClick={() => { void cancel() }}>{m.welcomeAuthCancel}</button>
</div> </div>
<div id="entry-actions" className="actions" hidden={page !== 'entry'}> <div id="entry-actions" className="actions" hidden={page !== 'entry'}>
<button id="sign-in" className="primary" type="button" onClick={() => { void start() }}>{m.welcomeSignIn}</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={() => { navigate('key') }}>{m.welcomeApiKey}</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>
<div id="key-actions" className="actions" hidden={page !== 'key'}> <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> <button id="save-key" className="primary" type="submit" form="key-form" disabled={busy || draft.trim() === ''}>{m.welcomeKeySave}</button>
+24 -3
View File
@@ -1,3 +1,4 @@
import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ /** 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 { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
import { desktopErrorState } from './startup-error.ts' import { desktopErrorState } from './startup-error.ts'
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.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 { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
import { DesktopPolicyTestAuth } from './policy-test-auth.ts' import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
@@ -376,6 +377,14 @@ async function main(): Promise<void> {
const browserGuests = new DesktopBrowserGuests(() => hostUrl) const browserGuests = new DesktopBrowserGuests(() => hostUrl)
let injections: readonly unknown[] = [] let injections: readonly unknown[] = []
let welcomeBackend: DesktopWelcomeBackend | undefined 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 stopAccount: (() => void) | undefined
let openedAttempt: string | undefined let openedAttempt: string | undefined
let returnedAttempt: string | undefined let returnedAttempt: string | undefined
@@ -408,7 +417,7 @@ async function main(): Promise<void> {
const backend = new DesktopBackendController((onFailure) => { const backend = new DesktopBackendController((onFailure) => {
const hostInspectPort = developmentHostInspectPort(development) const hostInspectPort = developmentHostInspectPort(development)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
hostInspectPort, process.env, onFailure, hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
primaryRuntime, primaryRuntime,
resources, (next) => { platformView.setSession(next) }) resources, (next) => { platformView.setSession(next) })
return { return {
@@ -419,6 +428,8 @@ async function main(): Promise<void> {
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections') if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
injections = ready.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('; ')) 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?.() stopAccount?.()
const accountBackend = welcomeBackend.account const accountBackend = welcomeBackend.account
stopAccount = accountBackend.watch((state) => { stopAccount = accountBackend.watch((state) => {
@@ -456,9 +467,11 @@ async function main(): Promise<void> {
const state = await accountBackend.state() const state = await accountBackend.state()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
}).catch(() => undefined) }).catch(() => undefined)
}) }, (enabled) => { analyticsEnabled = enabled })
}, },
stop: async () => { stop: async () => {
analyticsEnabled = false
stopAccount?.()
try { await host.stop(requireCleanStop) } try { await host.stop(requireCleanStop) }
catch (error) { catch (error) {
if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
@@ -567,6 +580,8 @@ async function main(): Promise<void> {
const result = await updateDialog.show(parent, confirmation) const result = await updateDialog.show(parent, confirmation)
if (result.response !== 0 || isMandatory()) return false 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) if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
try { try {
const stillActive = await host.updateTasks('lock') const stillActive = await host.updateTasks('lock')
@@ -591,11 +606,14 @@ async function main(): Promise<void> {
} }
return true 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 updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => { const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => {
void track('desktop_upgrade_click', {})
updateJournal?.action('download-requested') updateJournal?.action('download-requested')
const state = await updates.download(version) const state = await updates.download(version)
if (state.phase !== 'ready' || quitting) return state if (state.phase !== 'ready' || quitting) return state
@@ -1085,12 +1103,15 @@ async function main(): Promise<void> {
const showWelcome = (): Promise<void> => { const showWelcome = (): Promise<void> => {
if (quitting) return Promise.resolve() if (quitting) return Promise.resolve()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) { if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) {
if (!welcomeWindow.isVisible()) void track('auth_page_view', {})
welcomeWindow.show() welcomeWindow.show()
welcomeWindow.focus() welcomeWindow.focus()
return Promise.resolve() return Promise.resolve()
} }
openingWelcome ??= (async () => { openingWelcome ??= (async () => {
welcomeWindow = await openWelcomeWindow(locale, { welcomeWindow = await openWelcomeWindow(locale, {
analytics: track,
analyticsEnabled: () => Promise.resolve(analyticsEnabled),
takeNotice: () => { takeNotice: () => {
const notice = pendingWelcomeNotice const notice = pendingWelcomeNotice
pendingWelcomeNotice = undefined pendingWelcomeNotice = undefined
+4
View File
@@ -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') if (locale === undefined) throw new Error('desktop welcome: missing window locale')
const api: WelcomeApi = { const api: WelcomeApi = {
...resolveDesktopLocale(locale), ...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>, takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise<WelcomeNotice | undefined>,
startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise<AccountView>, startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise<AccountView>,
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise<AccountView>, cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise<AccountView>,
+4
View File
@@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator {
* @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary. * @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary.
* @param enabled - Whether this process has a packaged update source. * @param enabled - Whether this process has a packaged update source.
* @param currentVersion - Actual installed application version. * @param currentVersion - Actual installed application version.
* @param downloadResult - Once per completed download attempt, including platform preparation failures.
*/ */
constructor( constructor(
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState, private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
@@ -52,6 +53,7 @@ export class DesktopUpdateCoordinator {
private readonly updater: AppUpdater = autoUpdater, private readonly updater: AppUpdater = autoUpdater,
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
private readonly currentVersion: () => string = () => app.getVersion(), private readonly currentVersion: () => string = () => app.getVersion(),
private readonly downloadResult?: (success: boolean, reason?: string) => void,
) { ) {
if (updater === autoUpdater) { if (updater === autoUpdater) {
// electron-updater omits this internal transport property from its public declarations. // electron-updater omits this internal transport property from its public declarations.
@@ -108,9 +110,11 @@ export class DesktopUpdateCoordinator {
try { try {
await this.updater.downloadUpdate() await this.updater.downloadUpdate()
if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness') if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness')
this.downloadResult?.(true)
return this.setState({ phase: 'ready', version }) return this.setState({ phase: 'ready', version })
} catch (error) { } catch (error) {
this.downloaded = false this.downloaded = false
this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed')
return this.setState(this.failure(error, 'download')) return this.setState(this.failure(error, 'download'))
} }
}).finally(() => { this.downloadOperation = undefined }) }).finally(() => { this.downloadOperation = undefined })
+9
View File
@@ -1,11 +1,14 @@
/** Operations available to the isolated native welcome renderer. */ /** Operations available to the isolated native welcome renderer. */
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' 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' import type { DesktopLocale } from './locale.ts'
/** Private native welcome channels, installed only while its window exists. */ /** Private native welcome channels, installed only while its window exists. */
export const WELCOME_IPC = { export const WELCOME_IPC = {
saveApiKey: 'dsh-welcome:save-api-key', saveApiKey: 'dsh-welcome:save-api-key',
analytics: 'dsh-welcome:analytics',
analyticsEnabled: 'dsh-welcome:analytics-enabled',
skip: 'dsh-welcome:skip', skip: 'dsh-welcome:skip',
start: 'dsh-welcome:start', start: 'dsh-welcome:start',
cancel: 'dsh-welcome:cancel', 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. */ /** One-time notification retained by the main process until Welcome receives it. */
export type WelcomeNotice = 'session-expired' 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. */ /** Host-owned operations used by the welcome window. */
export interface WelcomeOperations { 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. */ /** @returns the pending notification, clearing it before another renderer can receive it. */
takeNotice(): Promise<WelcomeNotice | undefined> takeNotice(): Promise<WelcomeNotice | undefined>
/** @returns account state after starting a login attempt. */ /** @returns account state after starting a login attempt. */
+16 -2
View File
@@ -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. */ /** Native welcome operations using the shared Web authentication and RPC APIs. */
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
@@ -13,7 +14,11 @@ export interface WelcomeState {
/** Narrow operations available to the native welcome flow. */ /** Narrow operations available to the native welcome flow. */
export interface DesktopWelcomeBackend { export interface DesktopWelcomeBackend {
/** @returns the current Host policy; every read observes live configuration. */
analyticsEnabled(): Promise<boolean>
readonly account: DesktopAccountBackend 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. */ /** @returns Configured-key presence and the shared language preference, without credential values. */
read(): Promise<WelcomeState> read(): Promise<WelcomeState>
/** @returns The saved UI language without account or provider requests. */ /** @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' }) const authenticated = await send(authenticatedUrl, { credentials: 'include' })
await authenticated.body?.cancel() await authenticated.body?.cancel()
if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed') 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 rpcId = randomUUID()
const method = `${request.namespace}/${request.method}` const method = `${request.namespace}/${request.method}`
const response = await send(new URL(`/api/${method}`, origin).href, { 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' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }), body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
}) })
@@ -115,6 +123,12 @@ export async function connectDesktopWelcome(
return { return {
account, account,
read, 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() { async readLocalePreference() {
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} }) const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces') if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
+22 -3
View File
@@ -57,14 +57,16 @@ let disposeActiveHandlers: (() => void) | undefined
* @returns the visible window; a failed load destroys it before rejecting. * @returns the visible window; a failed load destroys it before rejecting.
*/ */
export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise<BrowserWindow> { 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?.() disposeActiveHandlers?.()
let active = true let active = true
const disposeHandlers = (): void => { const disposeHandlers = (): void => {
if (!active) return if (!active) return
active = false active = false
for (const channel of [ 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) 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') 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.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() })
ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => { ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => {
assertSender(event) assertSender(event)
@@ -108,6 +124,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
throw error throw error
} }
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile. // 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 return window
} }
+14 -3
View File
@@ -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') if (typeof address === 'string' || address === null) throw new Error('missing server address')
const connected = once(server, 'connection') const connected = once(server, 'connection')
const expired = vi.fn() const expired = vi.fn()
const onAnalyticsEnabledChanged = vi.fn()
const snapshot = { status: 'signed-out', attempt: null, const snapshot = { status: 'signed-out', attempt: null,
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
const received = Promise.withResolvers<undefined>() const received = Promise.withResolvers<undefined>()
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve('')) 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) onTestFinished(stop)
await connected 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 streams = new Map<string, string>()
const opened = Promise.withResolvers<undefined>() const opened = Promise.withResolvers<undefined>()
const socket = [...server.clients][0]! 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') if (!Buffer.isBuffer(data)) throw new Error('expected a Buffer WebSocket frame')
const frame = JSON.parse(data.toString('utf8')) as { endpoint: string; streamId: string } const frame = JSON.parse(data.toString('utf8')) as { endpoint: string; streamId: string }
streams.set(frame.endpoint, frame.streamId) streams.set(frame.endpoint, frame.streamId)
if (streams.size === 2) opened.resolve(undefined) if (streams.size === 3) opened.resolve(undefined)
}) })
await opened.promise await opened.promise
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot })) socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot }))
await received.promise 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() expect(expired).not.toHaveBeenCalled()
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' })) socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' }))
await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() }) await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() })
+48 -4
View File
@@ -172,6 +172,9 @@ const harness = await vi.hoisted(async () => {
}), }),
}) })
let accountListener: ((state: AccountView) => void) | undefined 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 nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
const trays: FakeTray[] = [] const trays: FakeTray[] = []
class FakeTray extends EventEmitter { class FakeTray extends EventEmitter {
@@ -184,14 +187,21 @@ const harness = await vi.hoisted(async () => {
const shellDialog = { isOpen: false, focus: vi.fn() } const shellDialog = { isOpen: false, focus: vi.fn() }
return { return {
failWindow(error: Error) { windowFailure = error }, 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, menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
platformDispose, platformDispose,
platformCloseAndWait, 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 accountListener = listener
return () => { accountListener = undefined } return () => { accountListener = undefined; analyticsEnabledListener = undefined }
}, },
publishAccount(state: AccountView) { accountListener?.(state) }, publishAccount(state: AccountView) { accountListener?.(state) },
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(), 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', () => ({ vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({ connectDesktopWelcome: async () => ({
analyticsEnabled: async () => harness.analyticsEnabled,
report: harness.analytics,
readLocalePreference: async () => null, readLocalePreference: async () => null,
read: async (): Promise<unknown> => (await harness.hosts.at(-1)!.fetch()).json() as Promise<unknown>, read: async (): Promise<unknown> => (await harness.hosts.at(-1)!.fetch()).json() as Promise<unknown>,
save: async () => ({ ok: true }), save: async () => ({ ok: true }),
@@ -396,6 +408,7 @@ beforeEach(() => {
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined) vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3') vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
harness.analyticsEnabled = true
}) })
afterEach(async () => { afterEach(async () => {
@@ -1638,6 +1651,23 @@ describe('desktop main startup', () => {
await expect(preparing).resolves.toBe(true) 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 () => { it('reports a Platform storage cleanup failure as preparation failure without stopping the Host', async () => {
const host = await readyForUpdate() const host = await readyForUpdate()
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
@@ -2034,7 +2064,9 @@ describe('desktop main startup', () => {
primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'), primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
profile: 'desktop-test-profile', 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.hosts[0]!.start).toHaveBeenCalledTimes(1)
expect(harness.windows).toHaveLength(1) expect(harness.windows).toHaveLength(1)
expect(window.urls).toEqual(['dsh-app://app/']) 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) harness.publishAccount(state)
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`) 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
})
+22 -2
View File
@@ -61,14 +61,15 @@ function fixture() {
}) })
const quitAndInstall = vi.fn() const quitAndInstall = vi.fn()
const beforeRestart = vi.fn(async () => true) const beforeRestart = vi.fn(async () => true)
const downloadResult = vi.fn()
const states: DesktopUpdateState[] = [] const states: DesktopUpdateState[] = []
const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater
const coordinator = new DesktopUpdateCoordinator( const coordinator = new DesktopUpdateCoordinator(
(state) => { states.push(state); return state }, (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) 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', () => { describe('desktop update coordinator', () => {
@@ -222,3 +223,22 @@ describe('desktop update coordinator', () => {
expect(f.events.listenerCount('download-progress')).toBe(0) 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')
})
+16
View File
@@ -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 } 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 === 'settings/describe') value = { namespaces }
else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }] 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 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 }])) 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 } }) return Response.json({ type: 'server-response', rpcId, result: { ok: true, value } })
@@ -34,6 +36,7 @@ describe('desktop welcome Web operations', () => {
const host = transport() const host = transport()
const backend = await connectDesktopWelcome(url, host.send) const backend = await connectDesktopWelcome(url, host.send)
expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' }) expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
expect(await backend.analyticsEnabled()).toBe(true)
expect(await backend.save('sk-example')).toEqual({ ok: true }) expect(await backend.save('sk-example')).toEqual({ ok: true })
expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example') expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null }) 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') 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 () => { it('refuses an unauthenticated Web launch', async () => {
const send = vi.fn<Parameters<typeof connectDesktopWelcome>[1]>(async () => new Response(null, { status: 401 })) const send = vi.fn<Parameters<typeof connectDesktopWelcome>[1]>(async () => new Response(null, { status: 401 }))
await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed') 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 stopAccount = vi.fn()
const api = { const api = {
takeNotice, takeNotice,
analytics: vi.fn(async (_event: string, _attributes: object) => {}),
analyticsEnabled: async () => true,
onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount), 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 })), 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 })), 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) expect(view.document.querySelector<HTMLElement>('#auth-page')!.hidden).toBe(true)
await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`) 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', () => ({ vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({ connectDesktopWelcome: async () => ({
analyticsEnabled: async () => false,
readLocalePreference: async () => state.preference, readLocalePreference: async () => state.preference,
read: async () => { read: async () => {
await state.beforeRead() 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: '' } }) state.accountState.mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } })
if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] }) if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] })
vi.useFakeTimers() vi.useFakeTimers()
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile') vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile')
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node') vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm') vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
+24 -2
View File
@@ -6,13 +6,13 @@ import { needsWelcome, WELCOME_IPC } from '../src/welcome-api.ts'
const electron = vi.hoisted(() => ({ const electron = vi.hoisted(() => ({
create: vi.fn<(options: unknown) => ReturnType<typeof createWindow>>(), create: vi.fn<(options: unknown) => ReturnType<typeof createWindow>>(),
root: '/desktop-app', 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', () => ({ vi.mock('electron', () => ({
app: { getAppPath: () => electron.root }, app: { getAppPath: () => electron.root },
BrowserWindow: vi.fn(function (options: unknown) { return electron.create(options) }), BrowserWindow: vi.fn(function (options: unknown) { return electron.create(options) }),
ipcMain: { 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}`) if (electron.handlers.has(name)) throw new Error(`duplicate IPC handler: ${name}`)
electron.handlers.set(name, handler) electron.handlers.set(name, handler)
}, },
@@ -40,6 +40,7 @@ function createWindow() {
beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() }) beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() })
const operations = { const operations = {
analyticsEnabled: async () => true,
takeNotice: async () => undefined, takeNotice: async () => undefined,
startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), 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 }), 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) expect(needsWelcome({ loggedIn: true, hasApiKey: true })).toBe(false)
}) })
}) })
it('accepts only permitted event fields and reads the current policy over IPC', async () => {
const window = createWindow()
electron.create.mockReturnValue(window)
const analytics = vi.fn(async () => {})
let enabled = true
await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, analytics, analyticsEnabled: async () => enabled })
const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
const policy = electron.handlers.get(WELCOME_IPC.analyticsEnabled)!
expect(await policy(own)).toBe(true)
enabled = false
expect(await policy(own)).toBe(false)
const report = electron.handlers.get(WELCOME_IPC.analytics)!
await report(own, 'auth_page_click', { button_name: 'api-key' })
expect(analytics).toHaveBeenLastCalledWith('auth_page_click', { button_name: 'api-key' })
await expect(report(own, 'auth_page_click', { button_name: 'api-key', token: 'private' })).rejects.toThrow('invalid analytics')
await expect(report(own, 'desktop_app_launch', {})).rejects.toThrow('invalid analytics')
await expect(report(own, 'auth_page_view', null)).rejects.toThrow('invalid analytics')
await report(own, 'api_key_save_click', {})
expect(analytics).toHaveBeenLastCalledWith('api_key_save_click', {})
})
+3
View File
@@ -15,6 +15,9 @@
}, },
{ {
"path": "../../packages/credentials/deepseek-account" "path": "../../packages/credentials/deepseek-account"
},
{
"path": "../../packages/client/product-analytics/tsconfig.client.json"
} }
] ]
} }
+3
View File
@@ -28,6 +28,9 @@
}, },
{ {
"path": "../../packages/util/atomic-write" "path": "../../packages/util/atomic-write"
},
{
"path": "../../packages/client/product-analytics/tsconfig.host.json"
} }
], ],
"exclude": [ "exclude": [
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133 en: 41d772192075e133
zh: 5ef3da69b4de1d8a zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services: /capability-seams-and-core-services:
en: 9fc7d396f78e95c7 en: a1b9afa5de328d60
zh: d06b4797d3016efc zh: b77327f7caab0d1a
+4
View File
@@ -112,6 +112,8 @@ flowchart LR
pkg_api_account_controller["api-account-controller"] pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"] pkg_authorization["authorization"]
svc_authorization["ctx.authorization<br/>Authorization flow registry"] 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"] pkg_otel["otel"]
svc_otel["ctx.otel<br/>Shared OTel reporting channels"] svc_otel["ctx.otel<br/>Shared OTel reporting channels"]
pkg_host_product_telemetry_otel["host-product-telemetry-otel"] pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
@@ -298,6 +300,7 @@ flowchart LR
pkg_client_connection --> svc_connection pkg_client_connection --> svc_connection
pkg_client_file_upload --> svc_fileUploads pkg_client_file_upload --> svc_fileUploads
pkg_client_modules --> svc_clientModules pkg_client_modules --> svc_clientModules
pkg_client_product_analytics --> svc_productAnalytics
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
pkg_command_feedback --> svc_sessionFeedback pkg_command_feedback --> svc_sessionFeedback
pkg_commands --> svc_commands 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.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.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.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.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.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. | | `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. |
+4
View File
@@ -114,6 +114,8 @@ flowchart LR
pkg_api_account_controller["api-account-controller"] pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"] pkg_authorization["authorization"]
svc_authorization["ctx.authorization<br/>Authorization flow registry"] 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"] pkg_otel["otel"]
svc_otel["ctx.otel<br/>Shared OTel reporting channels"] svc_otel["ctx.otel<br/>Shared OTel reporting channels"]
pkg_host_product_telemetry_otel["host-product-telemetry-otel"] pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
@@ -300,6 +302,7 @@ flowchart LR
pkg_client_connection --> svc_connection pkg_client_connection --> svc_connection
pkg_client_file_upload --> svc_fileUploads pkg_client_file_upload --> svc_fileUploads
pkg_client_modules --> svc_clientModules pkg_client_modules --> svc_clientModules
pkg_client_product_analytics --> svc_productAnalytics
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
pkg_command_feedback --> svc_sessionFeedback pkg_command_feedback --> svc_sessionFeedback
pkg_commands --> svc_commands 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.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.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.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.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.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 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
+20
View File
@@ -493,6 +493,26 @@ export interface Config {
``` ```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-hmr --> <!-- 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 --> <!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
<a id="deepseek-aidsh-client-shortcuts"></a> <a id="deepseek-aidsh-client-shortcuts"></a>
+20
View File
@@ -495,6 +495,26 @@ export interface Config {
``` ```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-hmr --> <!-- 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 --> <!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
<a id="deepseek-aidsh-client-shortcuts"></a> <a id="deepseek-aidsh-client-shortcuts"></a>
+2 -2
View File
@@ -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-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/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/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) | | `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` | | `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`) | - | | `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/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `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`) | - | | `slots/changed` | `ui-renderer` (`emit`) | - |
<!-- END GENERATED event-producer-consumer:undeclared --> <!-- END GENERATED event-producer-consumer:undeclared -->
+2 -2
View File
@@ -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-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/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/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) | | `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` | | `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`) | - | | `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/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `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`) | - | | `slots/changed` | `ui-renderer` (`emit`) | - |
<!-- END GENERATED event-producer-consumer:undeclared --> <!-- END GENERATED event-producer-consumer:undeclared -->
+2
View File
@@ -154,6 +154,7 @@ flowchart TD
pkg_client_hmr["client-hmr"] pkg_client_hmr["client-hmr"]
pkg_client_locale["client-locale"] pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"] pkg_client_modules["client-modules"]
pkg_client_product_analytics["client-product-analytics"]
pkg_client_resources["client-resources"] pkg_client_resources["client-resources"]
pkg_client_shortcuts["client-shortcuts"] pkg_client_shortcuts["client-shortcuts"]
pkg_client_store["client-store"] pkg_client_store["client-store"]
@@ -1401,6 +1402,7 @@ flowchart TD
| [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — |
| [`client-locale`](../packages/client/locale) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — |
| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
| [`client-resources`](../packages/client/resources) | `client` | — | | [`client-resources`](../packages/client/resources) | `client` | — |
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — |
+2
View File
@@ -156,6 +156,7 @@ flowchart TD
pkg_client_hmr["client-hmr"] pkg_client_hmr["client-hmr"]
pkg_client_locale["client-locale"] pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"] pkg_client_modules["client-modules"]
pkg_client_product_analytics["client-product-analytics"]
pkg_client_resources["client-resources"] pkg_client_resources["client-resources"]
pkg_client_shortcuts["client-shortcuts"] pkg_client_shortcuts["client-shortcuts"]
pkg_client_store["client-store"] pkg_client_store["client-store"]
@@ -1403,6 +1404,7 @@ flowchart TD
| [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — |
| [`client-locale`](../packages/client/locale) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — |
| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
| [`client-resources`](../packages/client/resources) | `client` | — | | [`client-resources`](../packages/client/resources) | `client` | — |
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — |
+6
View File
@@ -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. * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
*/ */
abstract getPlatformSession(): Promise<PlatformSession | null> 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) Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
+6
View File
@@ -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. * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
*/ */
abstract getPlatformSession(): Promise<PlatformSession | null> 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) Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with: # After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/product-telemetry.md # pnpm run verify-translation-pairing --write docs/subsystems/product-telemetry.md
/product-telemetry: /product-telemetry:
en: e1a8950c3b4743ed en: 04e9a32dfbe69355
zh: 3fff7e01a8f271e1 zh: 0e6c78274dd97466
/product-telemetry/record-types: /product-telemetry/record-types:
en: bf5eadedbc896f5a en: bf5eadedbc896f5a
zh: 158fce1609a85d6d zh: 158fce1609a85d6d
+31 -1
View File
@@ -2,7 +2,7 @@
English | [中文](product-telemetry.zh.md) 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. `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). 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> <a id="ctxproducttelemetry--producttelemetry"></a>
### `ctx.productTelemetry` — `ProductTelemetry` ### `ctx.productTelemetry` — `ProductTelemetry`
+31 -1
View File
@@ -2,7 +2,7 @@
[English](product-telemetry.md) | 中文 [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。记录入队时填写观测时间。 `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). 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> <a id="ctxproducttelemetry--producttelemetry"></a>
### `ctx.productTelemetry` — `ProductTelemetry` ### `ctx.productTelemetry` — `ProductTelemetry`
+2 -2
View File
@@ -6,8 +6,8 @@
en: 1c32778606f3027a en: 1c32778606f3027a
zh: db7994fdb50333c8 zh: db7994fdb50333c8
/deepseek-ai-dsh-api-remotes: /deepseek-ai-dsh-api-remotes:
en: b5349178e12f23fe en: f0e152bd9c846ec4
zh: dfdb4de9d99fbc7b zh: dba8ab5fa1181276
/deepseek-ai-dsh-api-remotes/summary: /deepseek-ai-dsh-api-remotes/summary:
en: 3c54c157bb823fd0 en: 3c54c157bb823fd0
zh: 0db6778e94ae671c zh: 0db6778e94ae671c
+2
View File
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md) 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 ## 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. 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.
+2
View File
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文 [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 运行时入口。 为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口拥有转发事件名单并向 API Gateway 注册应用事件 source;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
+2 -1
View File
@@ -94,6 +94,7 @@
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-office-to-pdf": "workspace:*", "@deepseek-ai/dsh-office-to-pdf": "workspace:*",
"@deepseek-ai/dsh-api-account-controller": "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:*"
} }
} }
+4 -1
View File
@@ -1,6 +1,8 @@
/** Platform-neutral assembly of generated Host Remote contributions. */ /** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from '@deepseek-ai/cordis' 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 agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote'
import commandsRemote from '@deepseek-ai/dsh-commands/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import accountRemote from '@deepseek-ai/dsh-api-account-controller/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>> = [] const disposers: Array<() => Promise<void>> = []
try { try {
for (const contribution of [ 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, pluginInventoryRemote, pluginManagerRemote, pluginRegistryProbeRemote, messageFeedbackRemote, sessionFeedbackRemote,
fileUploadsRemote, sessionReferencesRemote, fileUploadsRemote, sessionReferencesRemote,
permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
@@ -11,6 +11,7 @@
"src/types.ts" "src/types.ts"
], ],
"references": [ "references": [
{ "path": "../../client/product-analytics/tsconfig.client.json" },
{ {
"path": "../../../vendor/cordis" "path": "../../../vendor/cordis"
}, },
@@ -6,8 +6,8 @@
en: 82789442228fc62a en: 82789442228fc62a
zh: f1d511ad838187de zh: f1d511ad838187de
/session-controller: /session-controller:
en: fb78499ee63f41b9 en: c7a4c58ea6d3437b
zh: 6fff916b1fa26314 zh: 45c2b472a074bb1b
/session-controller/summary: /session-controller/summary:
en: 4914824c347fdab5 en: 4914824c347fdab5
zh: 684a7b7fa970c39f zh: 684a7b7fa970c39f
@@ -6,6 +6,8 @@ kind: "package-reference"
English | [中文](README.zh.md) 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 ## 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. `@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) | 中文 [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 使用它。 `@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 * 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 * turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an * 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. * @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation. * @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. * Borrow an already-retained Agent-scoped Context without extending its lifetime.
* @param id - session id. * @param id - session id.
@@ -452,6 +452,7 @@ export class ClientSessions implements ISessions {
sessionId: SessionId sessionId: SessionId
atSeq?: number atSeq?: number
increaseTitle?: boolean increaseTitle?: boolean
onCreated?: (childId: SessionId) => void
}): Promise<SessionId> { }): Promise<SessionId> {
const sourceTitle = opts.increaseTitle const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title ? 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) if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList() this.projectList()
const childId = result.value.sessionId const childId = result.value.sessionId
opts.onCreated?.(childId)
if (sourceTitle !== undefined) { if (sourceTitle !== undefined) {
const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle)) 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}`) 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.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
b.mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'rejected', { 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') .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.list.getSnapshot().byId[sid('child')]).toBeDefined()
expect(b.svc.binding(sid('child'))).toBeUndefined() expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0) expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
-1
View File
@@ -194,7 +194,6 @@ Boot itself changes no request prefix. `addHarnessSourceSection` places its sour
<a id="known-limitations-and-deferred-work"></a> <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. 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. - **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.
-1
View File
@@ -194,7 +194,6 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
<a id="known-limitations-and-deferred-work"></a> <a id="known-limitations-and-deferred-work"></a>
这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。 这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。
- **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。 - **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。
+2 -2
View File
@@ -6,8 +6,8 @@
en: af2e4e5296596e8f en: af2e4e5296596e8f
zh: 70c0b5ac93209d00 zh: 70c0b5ac93209d00
/deepseek-ai-dsh-web-app: /deepseek-ai-dsh-web-app:
en: f4da95d498221fcd en: 8523b283e794cf59
zh: 6cc704e7aa6f376d zh: 7764704f7e98f19b
/deepseek-ai-dsh-web-app/summary: /deepseek-ai-dsh-web-app/summary:
en: 0ebb6c3ca8c0a84e en: 0ebb6c3ca8c0a84e
zh: 4d85e02c769ddae0 zh: 4d85e02c769ddae0
+2
View File
@@ -7,6 +7,8 @@ kind: "package-bundle"
English | [中文](README.zh.md) 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 ## 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. 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.
+2
View File
@@ -7,6 +7,8 @@ kind: "package-bundle"
[English](README.md) | 中文 [English](README.md) | 中文
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
## 概述 ## 概述
运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印带认证信息的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。 运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印带认证信息的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。
+18
View File
@@ -42,6 +42,24 @@
# `dsh.client` rows are the browser roster the modules node half scans into # `dsh.client` rows are the browser roster the modules node half scans into
# window.__DSH_BOOT__; the modules row is simultaneously a host row. # window.__DSH_BOOT__; the modules row is simultaneously a host row.
- insert: - 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 # Host-owned opt-in sampled when a new Web session receives its preset
# delegation tools. The Plugins page edits this settings namespace. # delegation tools. The Plugins page edits this settings namespace.
- id: subagent-model-selection-settings - id: subagent-model-selection-settings
+5 -2
View File
@@ -173,7 +173,9 @@
"@deepseek-ai/dsh-api-account-controller": "workspace:*", "@deepseek-ai/dsh-api-account-controller": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings-account": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-account": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "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": { "peerDependencies": {
"@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis": "workspace:~",
@@ -193,6 +195,7 @@
"@deepseek-ai/dsh-shell-env": "workspace:*", "@deepseek-ai/dsh-shell-env": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-typert-registry": "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 })
+2 -2
View File
@@ -6,8 +6,8 @@
en: d88f75fc0178ba53 en: d88f75fc0178ba53
zh: 52bc7ae9551ab1cf zh: 52bc7ae9551ab1cf
/deepseek-ai-dsh-client-ui-chat: /deepseek-ai-dsh-client-ui-chat:
en: a0be18d7053df6b0 en: 02bd72c12ada3218
zh: 28c76e2cc32b7494 zh: 331b3a38b3ddfc6e
/deepseek-ai-dsh-client-ui-chat/summary: /deepseek-ai-dsh-client-ui-chat/summary:
en: fba5b2fb8080e2dd en: fba5b2fb8080e2dd
zh: 32c337123b7dfbcb zh: 32c337123b7dfbcb
+2
View File
@@ -6,6 +6,8 @@ kind: "package-reference"
English | [中文](README.zh.md) English | [中文](README.zh.md)
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
## Summary ## 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. 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.
+2
View File
@@ -6,6 +6,8 @@ kind: "package-reference"
[English](README.md) | 中文 [English](README.md) | 中文
桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
## 概述 ## 概述
使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏最终答案;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏最终答案;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。
+2 -1
View File
@@ -92,7 +92,8 @@
"@types/react-dom": "~18.3.0", "@types/react-dom": "~18.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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": { "dependencies": {
"@deepseek-ai/schemastery": "workspace:~" "@deepseek-ai/schemastery": "workspace:~"
+6 -1
View File
@@ -1,4 +1,5 @@
/** Register the Chat Conversation target, renderers, stats, and details surface. */ /** 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 { Context } from '@deepseek-ai/cordis'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -245,7 +246,11 @@ export function apply(ctx: Context): void {
read: () => chatScrollPositions.get(sessionId) ?? null, read: () => chatScrollPositions.get(sessionId) ?? null,
}, },
forkAt: (seq) => { 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) }) .then((childId) => { ctx.uiWorkspace.openSession(childId) })
.catch(() => { .catch(() => {
// Fork or child-title failure leaves the source view unchanged. // 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 // @vitest-environment jsdom
/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */ /** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -170,18 +171,36 @@ describe('Chat inject API', () => {
void injected.loadThrough(SessionSeq(42)) void injected.loadThrough(SessionSeq(42))
expect(b.session.loadThrough).toHaveBeenCalledWith(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) injected.forkAt(17)
await vi.waitFor(() => { await vi.waitFor(() => {
expect(b.openSession).toHaveBeenCalledWith(ROOT) expect(b.openSession).toHaveBeenCalledWith(ROOT)
}) })
expect(b.runtime.sessions.calls).toContainEqual({ 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')) const fork = vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork failed'))
injected.forkAt(18) injected.forkAt(18)
await vi.waitFor(() => { 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() await b.runtime.dispose()
}) })
@@ -113,6 +113,9 @@
}, },
{ {
"path": "../ui-input-trigger" "path": "../ui-input-trigger"
},
{
"path": "../product-analytics/tsconfig.client.json"
} }
], ],
"exclude": [ "exclude": [
@@ -6,8 +6,8 @@
en: 0ca51f37788dcbdb en: 0ca51f37788dcbdb
zh: 14d73c6bc8d4e17f zh: 14d73c6bc8d4e17f
/deepseek-ai-dsh-client-ui-conversation: /deepseek-ai-dsh-client-ui-conversation:
en: 71d52109076d859d en: 77ecbd074ef3a3ce
zh: 8e63171fbd9bde48 zh: 2c0e2b720e3b201e
/deepseek-ai-dsh-client-ui-conversation/summary: /deepseek-ai-dsh-client-ui-conversation/summary:
en: bac0bf0d7330f3b1 en: bac0bf0d7330f3b1
zh: 84a895f4b26d8490 zh: 84a895f4b26d8490
@@ -18,8 +18,8 @@
en: 13ae68865fa315d7 en: 13ae68865fa315d7
zh: c152f020abf20f35 zh: c152f020abf20f35
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: ea2f1e81b1f9a38a en: 7f1d7a29d2238f3a
zh: a2d4745b3e93e5a9 zh: f7a66d2f34a1cd42
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382 en: bb1244310f5c0382
zh: 824b84077432b559 zh: 824b84077432b559
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md) English | [中文](README.zh.md)
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
## Summary ## 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. `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. 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. 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. 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) | 中文 [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。 `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 使用已配置的投递模式,严格的 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。 默认发送采用乐观提交: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 恢复失败草稿,保留用户随后输入的文字。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
+2 -1
View File
@@ -96,7 +96,8 @@
"@lexical/history": "^0.49.0", "@lexical/history": "^0.49.0",
"@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-settings": "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": [ "files": [
"lib/index.js", "lib/index.js",
@@ -1,4 +1,5 @@
/** Registers the target-neutral Conversation assembly, shell, input, and docks. */ /** 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 type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery' import z from '@deepseek-ai/schemastery'
import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' 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. */ /** Keyboard gesture whose delivery mode the submission policy resolves. */
export type ComposerSubmitGesture = 'enter' | 'accelerated' 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. */ /** The shell-owned Lexical editor the composer binds its contenteditable to. */
readonly editor: LexicalEditor readonly editor: LexicalEditor
/** Submit with an explicit delivery mode resolved by the submission policy (Enter gestures and the primary Send button). */ /** 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 * Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer * 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 { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Branded } from '@deepseek-ai/dsh-brand' import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts' 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. */ /** Attachment payload passed to a claimed command submission. */
export type SubmitAttachment = 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. * THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
* @param mode - delivery intent retained through asynchronous adjudication and serialization. * @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 * Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here. * command results and business notifications render through here.
@@ -283,6 +283,8 @@ export interface SubmitAttempt {
readonly draftSnapshot: string readonly draftSnapshot: string
/** Default-message delivery intent retained while slash adjudication is pending. */ /** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode 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. */ /** The editor applied a claim-token replacement: enter claimed. */
| { readonly type: 'claim'; readonly claim: CommandClaim } | { readonly type: 'claim'; readonly claim: CommandClaim }
/** Enter submission with the current clipboard projection. */ /** 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: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
/** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */ /** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */
@@ -134,7 +134,7 @@ export function installDraftKeymap(
g.running, g.running,
accelerated ? 'accelerated' : 'enter', accelerated ? 'accelerated' : 'enter',
g.steeringAvailable, g.steeringAvailable,
)) ), 'enter')
}, },
intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) }, intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) },
pasteText: (text) => { pasteText: (text) => {
@@ -22,7 +22,7 @@ import type {
import type { import type {
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan, ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan,
} from '../contract/draft-editor.ts' } 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 { SubmitMachine } from './machine.ts'
import { DraftEditorRuntime } from './editor/runtime.ts' import { DraftEditorRuntime } from './editor/runtime.ts'
import type { EditorProjection } from './editor/projection.ts' import type { EditorProjection } from './editor/projection.ts'
@@ -41,6 +41,10 @@ export interface PopupDismissFace {
export interface SessionInputDeps { export interface SessionInputDeps {
/** Session-scope ctx handed to claim.submit transactions. */ /** Session-scope ctx handed to claim.submit transactions. */
actx: Context 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. */ /** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */
inputTriggers?: (() => InputTriggerController | undefined) | undefined inputTriggers?: (() => InputTriggerController | undefined) | undefined
/** PopupSelect shell face resolver (dismissal on submit lock / escape). */ /** 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 * (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen. * 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.draft.trim() === '' && this.attachmentIds.length > 0) {
if (this.snapshot.phase === 'plain') { if (this.snapshot.phase === 'plain') {
const attachmentIds = [...this.attachmentIds] const attachmentIds = [...this.attachmentIds]
@@ -299,6 +312,7 @@ export class SessionInputShell implements SessionInput {
const flight = this.attachmentFlightSeq const flight = this.attachmentFlightSeq
this.attachmentFlights.set(flight, { controller, attachmentIds }) this.attachmentFlights.set(flight, { controller, attachmentIds })
this.commitSend(attachmentIds) this.commitSend(attachmentIds)
this.notifySubmission(submission)
void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => { void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => {
if (this.disposed || !this.attachmentFlights.delete(flight)) return if (this.disposed || !this.attachmentFlights.delete(flight)) return
if (outcome.kind === 'success') 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)) this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft))
return 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 const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') { if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss() this.deps.popup?.()?.dismiss()
@@ -572,7 +586,8 @@ export class SessionInputShell implements SessionInput {
/** Dispatch + execute, refreshing the claim decoration when the styled token flips. */ /** Dispatch + execute, refreshing the claim decoration when the styled token flips. */
private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void { private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
const beforeToken = this.activeClaimToken() 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() if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration()
} }
@@ -633,6 +648,7 @@ export class SessionInputShell implements SessionInput {
draft: string, draft: string,
mode: InputSubmitMode, mode: InputSubmitMode,
): void { ): void {
this.notifySubmission(attempt.submission)
const attachmentIds = [...this.attachmentIds] const attachmentIds = [...this.attachmentIds]
this.attachmentIds = [] this.attachmentIds = []
const occurrences = this.projection.occurrences 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. */ /** Settle one detached default send independently of other sends. */
private settleSink( private settleSink(
attempt: SubmitAttempt, attempt: SubmitAttempt,
@@ -7,6 +7,10 @@
* listeners on each Session context and owns the default-sink choreography: every session is a * 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. * 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 { Context } from '@deepseek-ai/cordis'
import type { import type {
ISessions, SessionBinding, SessionFace, 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 { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts' import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts' import { SessionInputShell } from './facade.ts'
import { reportMessageSubmission } from './submission-analytics.ts'
/** Structural command face for per-session popup resolution. */ /** Structural command face for per-session popup resolution. */
interface CommandFace { interface CommandFace {
@@ -90,6 +95,23 @@ export class InputHub implements SessionInputResolver {
const { session, ctx: actx } = binding const { session, ctx: actx } = binding
const shell = new SessionInputShell({ const shell = new SessionInputShell({
actx, 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), inputTriggers: () => this.controller(actx),
popup: () => this.popup(actx), popup: () => this.popup(actx),
inbox: session.projections.faceOf('inbox') as ObservableSnapshot<InboxState | undefined>, 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 * at Enter, so the editor can clear immediately and accept another message
* while earlier admissions remain in flight. * 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' import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts'
/** Exhaustiveness backstop for the closed InputEvent union. */ /** Exhaustiveness backstop for the closed InputEvent union. */
@@ -77,7 +77,7 @@ export class SubmitMachine {
switch (ev.type) { switch (ev.type) {
case 'draft-changed': return this.onDraftChanged(ev.draft) case 'draft-changed': return this.onDraftChanged(ev.draft)
case 'claim': return this.onClaim(ev.claim) 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 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev) case 'submit-settled': return this.onSubmitSettled(ev)
@@ -106,28 +106,28 @@ export class SubmitMachine {
} }
/** Mint an attempt and controller without assigning its lifecycle owner. */ /** 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 attempt: SubmitAttempt
readonly controller: AbortController readonly controller: AbortController
} { } {
const controller = new AbortController() const controller = new AbortController()
this.seq += 1 this.seq += 1
return { 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, controller,
} }
} }
/** Mint the frozen command/adjudication attempt. */ /** Mint the frozen command/adjudication attempt. */
private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt { private beginAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
const flight = this.mintAttempt(mode, draft) const flight = this.mintAttempt(mode, draft, submission)
this.inflight = flight this.inflight = flight
return flight.attempt return flight.attempt
} }
/** Mint an ordinary send that leaves the phase plain. */ /** Mint an ordinary send that leaves the phase plain. */
private beginDetached(mode: InputSubmitMode, draft: string): SubmitAttempt { private beginDetached(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
const flight = this.mintAttempt(mode, draft) const flight = this.mintAttempt(mode, draft, submission)
this.detached.set(flight.attempt.seq, flight.controller) this.detached.set(flight.attempt.seq, flight.controller)
this.claim = undefined this.claim = undefined
this.phase = 'plain' 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 === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) { if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode, draft) const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'submitting' this.phase = 'submitting'
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }] return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }]
} }
const trimmed = draft.trim() const trimmed = draft.trim()
if (trimmed === '') return [] if (trimmed === '') return []
if (trimmed.startsWith('/')) { if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt(mode, draft) const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'adjudicating' this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft }] return [{ type: 'adjudicate', attempt, draft }]
} }
return this.detachedEffects(this.beginDetached(mode, draft)) return this.detachedEffects(this.beginDetached(mode, draft, submission))
} }
private onAdjudicated( 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 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. */ /* 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 // Claim ghost hint: rendered by CSS as generated content after the last
@@ -5,7 +5,7 @@
*/ */
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { Context } from '@deepseek-ai/cordis' 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 { SessionInputShell } from '../src/client/input/facade.ts'
import type { DraftAttachmentId } from '../src/client/contract/input.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts'
@@ -210,17 +210,20 @@ describe('reference submission', () => {
describe('submit transaction hardening', () => { describe('submit transaction hardening', () => {
it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => { it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => {
const submitted = vi.fn()
let settle!: (outcome: SubmitOutcome) => void let settle!: (outcome: SubmitOutcome) => void
const sink = vi.fn(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve })) const sink = vi.fn(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
const shell = new SessionInputShell({ const shell = new SessionInputShell({
actx: {} as Context, actx: {} as Context,
defaultSink: sink, defaultSink: sink,
commandAttachments, commandAttachments,
messageSubmitted: submitted,
}) })
expect(shell.addAttachments(['img-1' as DraftAttachmentId])).toBe(true) expect(shell.addAttachments(['img-1' as DraftAttachmentId])).toBe(true)
shell.submit('queue') shell.submit('queue')
shell.submit('queue') shell.submit('queue')
expect(sink).toHaveBeenCalledTimes(1) expect(sink).toHaveBeenCalledTimes(1)
expect(submitted).toHaveBeenCalledOnce()
settle({ kind: 'success' }) settle({ kind: 'success' })
await vi.waitFor(() => { await vi.waitFor(() => {
expect(shell.snapshot.attachmentIds).toEqual([]) expect(shell.snapshot.attachmentIds).toEqual([])
@@ -229,6 +232,9 @@ describe('submit transaction hardening', () => {
expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true) expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true)
shell.submit('queue') shell.submit('queue')
expect(sink).toHaveBeenCalledTimes(2) 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 () => { 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) 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() 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": "../shortcuts/tsconfig.client.json"
},
{
"path": "../product-analytics/tsconfig.client.json"
} }
], ],
"exclude": [ "exclude": [
@@ -6,8 +6,8 @@
en: f39813648aeef5a0 en: f39813648aeef5a0
zh: eea25094e8bfc031 zh: eea25094e8bfc031
/deepseek-ai-dsh-client-ui-model-selection: /deepseek-ai-dsh-client-ui-model-selection:
en: 5e007d579a0e22f0 en: 4cb6feb12e46f3a0
zh: edfff0d021712077 zh: eea39dd76fe1fa8b
/deepseek-ai-dsh-client-ui-model-selection/summary: /deepseek-ai-dsh-client-ui-model-selection/summary:
en: 2fb361ef704528fb en: 2fb361ef704528fb
zh: 2537d2afb4d2cb45 zh: 2537d2afb4d2cb45
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md) English | [中文](README.zh.md)
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
## Summary ## 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. 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) | 中文 [English](README.md) | 中文
桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
## 概述 ## 概述
Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。 Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。
@@ -65,7 +65,8 @@
"@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*",
"clsx": "^2.1.1" "clsx": "^2.1.1",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
}, },
"files": [ "files": [
"lib/index.js", "lib/index.js",
@@ -4,6 +4,7 @@
* Session's durable selection projection, then submit through the same * Session's durable selection projection, then submit through the same
* selectModel call. A switch made in either entry updates this shared state. * 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 { import type {
ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection, ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection,
} from '@deepseek-ai/dsh-api-session-controller/types' } 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 available - whether this session may use Agent-bound model RPCs.
* @param catalog - Host-generation catalog shared by every Session. * @param catalog - Host-generation catalog shared by every Session.
* @param projected - durable model selection projected from Session history. * @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( constructor(
private readonly sessions: Pick<TypertClientRemote['session'], 'selectModel'>, private readonly sessions: Pick<TypertClientRemote['session'], 'selectModel'>,
@@ -59,6 +62,8 @@ export class ModelDirectory {
private readonly available: () => boolean, private readonly available: () => boolean,
private readonly catalog: ModelCatalogDirectory, private readonly catalog: ModelCatalogDirectory,
private readonly projected: ObservableSnapshot<unknown>, private readonly projected: ObservableSnapshot<unknown>,
private readonly isBlank: () => boolean,
private readonly track?: TrackProductEvent,
) { ) {
this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() }) this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() })
this.unsubscribeSelection = projected.subscribe(() => { this.syncInputs() }) this.unsubscribeSelection = projected.subscribe(() => { this.syncInputs() })
@@ -85,6 +90,9 @@ export class ModelDirectory {
*/ */
async select(selection: ModelSelection): Promise<RemoteResult<void>> { async select(selection: ModelSelection): Promise<RemoteResult<void>> {
this.assertAvailable() 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 const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.pending = selection; s.error = null }) this.store.update((s) => { s.status = 'selecting'; s.pending = selection; s.error = null })
const result = await this.sessions.selectModel({ const result = await this.sessions.selectModel({
@@ -106,6 +114,14 @@ export class ModelDirectory {
}) })
return result 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.store.update((s) => { s.status = 'ready'; s.pending = null; s.error = null })
this.syncInputs() this.syncInputs()
return { ok: true, value: undefined } return { ok: true, value: undefined }
@@ -12,6 +12,7 @@
* strings, and it models global+shadow named registries — this is a * strings, and it models global+shadow named registries — this is a
* per-session singleton with no global layer to merge. * 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 { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -77,6 +78,8 @@ export class ModelDirectoryResolver extends Service {
() => sessions.subagentAddress(sessionId) === undefined, () => sessions.subagentAddress(sessionId) === undefined,
this.catalog, this.catalog,
binding.session.projections.faceOf('modelSelection'), binding.session.projections.faceOf('modelSelection'),
() => binding.session.getSnapshot().blank,
(name, attributes) => this.ctx.get('productAnalytics')?.track(name, attributes),
) )
live.directories.set(binding, directory) live.directories.set(binding, directory)
actx.effect(() => () => { actx.effect(() => () => {
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more