Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ jobs:
cat > release-notes.md <<'EOF'
WorkDSH Desktop 将 WorkBuddy 风格的项目、资料、专家、技能和连接器工作台与 DeepSeek Harness 插件能力结合。

- 本地插件压缩包按内容校验后保存到应用管理目录,安装后移动或删除原始下载文件不再阻挡后续安装。
- 新会话创建独立空白会话,不沿用旧草稿的专家、MCP 或资料选择;明确切换专家仍保留当前草稿与引用。
- 修复 macOS 召唤或切换专家时的 Dock 闪动,后台浏览器从原生启动阶段保持无 Dock 图标,主窗口正常显示。
- 对话框可直接召唤专家并显示当前专家名称;切换专家保留资料库的可点击引用和 MCP 选择。
- 资料库支持 CSV/XLSX 导入;专家、资料库和 MCP 的选择浮层保持一致。
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dsh-plugin-desktop",
"version": "2.0.6-alpha.4",
"version": "2.0.6-alpha.5",
"description": "WorkDSH Electron carrier for a pinned DeepSeek Harness runtime Profile",
"license": "MIT",
"publishConfig": {
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/local-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { load, dump } from 'js-yaml'
import { syncBundledCompatibility } from './runtime-compatibility.ts'
import { cachedPackageManager } from './plugin-archive-cache.ts'
import { enterpriseNamespace } from './connection-mode.ts'
import type { EnterpriseMember, Authority } from './enterprise-auth.ts'

Expand Down Expand Up @@ -142,8 +143,8 @@ export function enterpriseEnvironment(root: string, executable: string, parent:

export function officialLauncher(source: string, home: string, executable: string, packageManager: string = join(dirname(executable), '..', '..', 'pnpm', 'bin', 'pnpm.cjs')): string {
const cli = join(source, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
const pnpm = packageManager
if (!existsSync(cli) || !existsSync(pnpm)) throw new Error('安装包缺少官方 DSH 或锁定的插件安装工具')
if (!existsSync(cli) || !existsSync(packageManager)) throw new Error('安装包缺少官方 DSH 或锁定的插件安装工具')
const pnpm = cachedPackageManager(home, packageManager)
const launcher = join(home, '.workdsh-launch.mjs')
writeFileSync(launcher, `import { runCli } from ${JSON.stringify(pathToFileURL(cli).href)};\nawait runCli({ manageDesktopProfile: true, packageManager: { command: ${JSON.stringify(executable)}, args: [${JSON.stringify(pnpm)}] } });\n`, { mode: 0o600 })
return launcher
Expand All @@ -157,6 +158,7 @@ export function officialHostLauncher(source: string, home: string, executable: s
const anchor = join(source, 'profile-installation.json'), profile = join(home, 'profiles', 'workdsh')
if (!existsSync(anchor)) throw new Error('安装包缺少完整官方与 WorkDSH 安装清单')
const launcher = join(home, '.workdsh-host.mjs')
packageManager = cachedPackageManager(home, packageManager)
writeFileSync(launcher, `import { runProfile } from ${JSON.stringify(pathToFileURL(boot).href)};\nimport { loadProfileDirectory, loadLayeredEnv, reportSkippedBundles } from ${JSON.stringify(pathToFileURL(appBoot).href)};\nconst installAnchor = ${JSON.stringify(anchor)};\nconst profile = loadProfileDirectory('dsh', ${JSON.stringify(profile)}, installAnchor);\nreportSkippedBundles('dsh', profile);\nawait runProfile({ environment: loadLayeredEnv('dsh'), profile: 'workdsh', resolvedProfile: { profile, installAnchor }, patchFiles: ${JSON.stringify(patches)}, args: process.argv.slice(2), packageManager: { command: ${JSON.stringify(executable)}, args: [${JSON.stringify(packageManager)}] } });\n`, { mode: 0o600 })
return launcher
}
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/src/plugin-archive-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { join } from 'node:path'
import { writeFileSync } from 'node:fs'

/** Delegate to the bundled pnpm after retaining local archives in this account's Home. */
export function cachedPackageManager(home: string, pnpm: string): string {
const runner = join(home, '.workdsh-package-manager.mjs')
writeFileSync(runner, String.raw`import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
const cache = join(fileURLToPath(new URL('.', import.meta.url)), 'plugin-archives');
const args = process.argv.slice(2);
if (args.includes('add')) {
for (let i = 0; i < args.length; i++) {
const spec = args[i];
if (!/\.(tgz|tar\.gz)$/i.test(spec) || /^(https?:|npm:)/i.test(spec)) continue;
const source = spec.startsWith('file://') ? fileURLToPath(spec) : resolve(spec.startsWith('file:') ? spec.slice(5) : spec);
if (!statSync(source).isFile()) throw new Error('Plugin archive is not a file: ' + source);
const bytes = readFileSync(source);
const digest = createHash('sha256').update(bytes).digest('hex');
mkdirSync(cache, { recursive: true, mode: 0o700 });
const destination = join(cache, digest + '.tgz');
try { writeFileSync(destination, bytes, { flag: 'wx', mode: 0o600 }); }
catch (error) { if (error.code !== 'EEXIST') throw error; }
if (createHash('sha256').update(readFileSync(destination)).digest('hex') !== digest) throw new Error('Plugin archive cache integrity failure');
args[i] = destination;
}
}
const child = spawn(process.execPath, [${JSON.stringify(pnpm)}, ...args], { stdio: 'inherit', windowsHide: true });
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); });
`, { mode: 0o600 })
return runner
}
2 changes: 1 addition & 1 deletion apps/desktop/tests/local-runtime.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ it('isolates account/backend directories, device records and credential/skill en
it('delegates startup and plugin installation to official runCli using one packaged Node and pnpm', () => {
const { source, home } = fixture(); materializeRuntimeProfile(source, home)
const launcher = readFileSync(officialLauncher(source, home, '/runtime/node', join(source, 'node_modules/pnpm/bin/pnpm.cjs')), 'utf8')
expect(launcher).toContain('runCli({ manageDesktopProfile: true, packageManager:'); expect(launcher).toContain(JSON.stringify(join(source, 'node_modules/pnpm/bin/pnpm.cjs')))
expect(launcher).toContain('runCli({ manageDesktopProfile: true, packageManager:'); expect(launcher).toContain('.workdsh-package-manager.mjs'); expect(readFileSync(join(home, '.workdsh-package-manager.mjs'), 'utf8')).toContain(JSON.stringify(join(source, 'node_modules/pnpm/bin/pnpm.cjs')))
expect(launcher).not.toContain('token'); expect(launcher).not.toContain('createHost')
const host = readFileSync(officialHostLauncher(source, home, '/runtime/node', join(source, 'node_modules/pnpm/bin/pnpm.cjs'), ['/user/patch.yml']), 'utf8')
expect(host).toContain('runProfile({ environment: loadLayeredEnv')
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/tests/plugin-archive-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { afterEach, expect, test } from 'vitest'
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execFileSync } from 'node:child_process'
import { cachedPackageManager } from '../src/plugin-archive-cache.ts'

const homes: string[] = []
afterEach(() => { for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) })
test('local archives are retained by content and pnpm records the managed path', () => {
const home = mkdtempSync(join(tmpdir(), 'workdsh-plugin-cache-')); homes.push(home)
const fake = join(home, 'pnpm.mjs')
writeFileSync(fake, 'console.log(JSON.stringify(process.argv.slice(2)))')
const source = join(home, 'download with spaces.tgz'); writeFileSync(source, 'archive content')
const runner = cachedPackageManager(home, fake)
const args = JSON.parse(execFileSync(process.execPath, [runner, 'add', source, '--save-exact'], { encoding: 'utf8' })) as string[]
expect(args[0]).toBe('add'); expect(args[2]).toBe('--save-exact')
expect(args[1]).toMatch(/plugin-archives[/\\][a-f0-9]{64}\.tgz$/)
rmSync(source)
expect(readFileSync(args[1]!, 'utf8')).toBe('archive content')
expect(JSON.parse(execFileSync(process.execPath, [runner, 'install', '--frozen-lockfile'], { encoding: 'utf8' }))).toEqual(['install', '--frozen-lockfile'])
writeFileSync(args[1]!, 'corrupted'); writeFileSync(source, 'archive content')
expect(() => execFileSync(process.execPath, [runner, 'add', source], { stdio: 'pipe' })).toThrow()
})
test('registry installs pass through and personal/member caches are separate', () => {
const home = mkdtempSync(join(tmpdir(), 'workdsh-plugin-cache-')); homes.push(home)
const fake = join(home, 'pnpm.mjs'); writeFileSync(fake, 'console.log(JSON.stringify(process.argv.slice(2)))')
const member = join(home, 'member'); mkdirSync(member)
const personalRunner = cachedPackageManager(home, fake), memberRunner = cachedPackageManager(member, fake)
expect(memberRunner).not.toBe(personalRunner)
expect(JSON.parse(execFileSync(process.execPath, [memberRunner, 'add', '@company/plugin@1.0.0'], { encoding: 'utf8' }))).toEqual(['add', '@company/plugin@1.0.0'])
})
3 changes: 3 additions & 0 deletions apps/web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions apps/web/scripts/probe-expert-conversation-entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,15 @@ try {
await connectors.getByRole('button', { name: '管理连接器', exact: true }).click();
await expect(connectors).toHaveCount(0);
pass('MCP management footer dismisses the picker and opens management');
await featureApi('connectors', 'set-selection', { sessionId: comboCreation.sessionId, connectorIds: selectedMcp });
await page.getByText('New Session', { exact: true }).first().click();
await expect(page.getByRole('button', { name: '召唤专家', exact: true })).toHaveText('');
await expect(page.getByRole('button', { name: '连接器', exact: true })).toBeVisible();
await expect(editor).toHaveText('');
await expect(editor.locator('[data-composer-chip="workdsh-library"]')).toHaveCount(0);
assert.equal((await featureApi('library', 'task-selection', { sessionId: comboCreation.sessionId })).length, 1);
assert.deepEqual(await featureApi('connectors', 'selection', { sessionId: comboCreation.sessionId }), selectedMcp);
pass('New Session starts without expert, MCP or Library draft while retaining the previous task references');
assert.deepEqual(browserErrors, []);
await writeFile(join(artifacts, 'report.json'), JSON.stringify({ checks, sessionId: creation.sessionId, home }, null, 2));
} catch (error) {
Expand Down
7 changes: 5 additions & 2 deletions packages/bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
"@deepseek-ai/dsh-client-ui-sidebar-right",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-api-session-controller"
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-api-workspace-controller",
"@deepseek-ai/dsh-client-ui-workspace"
]
}
},
Expand Down Expand Up @@ -76,7 +78,8 @@
"workdsh-plugin-workbench": "workspace:*",
"workdsh-ui": "workspace:*",
"@types/node": "22.19.0",
"@deepseek-ai/dsh-agent": "0.2.0-rc.2"
"@deepseek-ai/dsh-agent": "0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-workspace": "0.2.0-rc.2"
},
"dependencies": {
"@deepseek-ai/dsh-browser-use": "0.2.0-rc.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/bundle/src/client/harness/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ShellAppearance } from '../components/ShellAppearance.js';
import { installFreshSessionNavigation } from './fresh-session.js';
import { communityMarketView, type MarketHost } from '../components/CommunityMarket.js';
import type { Context } from '@deepseek-ai/cordis';
import type {} from '@deepseek-ai/dsh-api-remotes/client';
Expand Down Expand Up @@ -32,6 +33,7 @@ const productViews: Readonly<Record<string, string>> = {
};

export function apply(ctx: Context): void {
installFreshSessionNavigation(ctx);
ctx.inject(['market'], scope => {
if (scope.market.version !== 1) return;
scope.market.setSettingsVisible(false);
Expand Down
28 changes: 28 additions & 0 deletions packages/bundle/src/client/harness/fresh-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Context } from '@deepseek-ai/cordis';
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client';
import type {} from '@deepseek-ai/dsh-api-workspace-controller/client';
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client';

/** A New Session is distinct from reopening an unsent draft or switching experts. */
export function installFreshSessionNavigation(ctx: Context): void {
ctx.inject(['uiWorkspace', 'workspaces', 'sessions'], scope => scope.effect(() => {
const owner = scope.uiWorkspace;
const original = owner.startSession;
const sessions = scope.sessions as unknown as ISessions;
let disposed = false;
const start: typeof original = workspaceId => {
const current = Object.values(sessions.list.getSnapshot().byId).find(row => (row.retainedBy.mainView ?? 0) > 0);
const workspaces = scope.workspaces.list.getSnapshot().items;
const workspace = workspaceId ? workspaces.find(row => row.workspaceId === workspaceId)
: workspaces.find(row => current && row.sessionIds.includes(current.id)) ?? workspaces[0];
if (!workspace) { original.call(owner, workspaceId); return; }
const navigation = scope.layout.beginNavigation();
// No sessionId: the official controller must allocate a new, unbound Session.
void sessions.create({ workspaceId: workspace.workspaceId }).then(id => {
if (!disposed && !navigation.aborted) owner.openSession(id);
}).catch(error => { if (!disposed && !navigation.aborted) console.error('无法新建会话', error); });
};
owner.startSession = start;
return () => { disposed = true; if (owner.startSession === start) owner.startSession = original; };
}, 'workdsh.fresh-session-navigation'));
}
Loading