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
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.

25 changes: 25 additions & 0 deletions apps/web/scripts/probe-expert-conversation-entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,31 @@ try {
await connectorButton.click();
await useConnector.click();
await expect(connectors.getByRole('button', { name: '从本次对话移除 浮层 MCP 验收', exact: true })).toHaveText('已选', { timeout: 20000 });
await page.keyboard.press('Escape');
await editor.fill('组合任务草稿');
await libraryButton.click();
await libraryPicker.getByRole('button', { name: '打开 浮层验收资料', exact: true }).click();
await libraryPicker.getByRole('button', { name: '添加 浮层体验验收.md', exact: true }).click();
await expect(editor.locator('[data-composer-chip="workdsh-library"]')).toHaveCount(1);
await page.getByRole('button', { name: '召唤专家', exact: true }).click();
const comboResponse = page.waitForResponse(response => response.url().endsWith('/api/workdsh-experts') && response.request().postDataJSON()?.endpoint === 'create-execution');
await choice.click();
const comboCreation = (await (await comboResponse).json()).value;
await expect(picker).toHaveCount(0);
await expect(editor).toContainText('组合任务草稿');
await expect(editor.locator('[data-composer-chip="workdsh-library"]')).toHaveCount(1);
await expect(page.getByRole('button', { name: '连接器:浮层 MCP 验收', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '召唤专家', exact: true })).toContainText('门店经营分析验收专家');
const selectedLibrary = await featureApi('library', 'task-selection', { sessionId: comboCreation.sessionId });
assert.equal(selectedLibrary.length, 1);
assert.equal(selectedLibrary[0].name, '浮层体验验收.md');
const selectedMcp = await featureApi('connectors', 'selection', { sessionId: comboCreation.sessionId });
assert.equal(selectedMcp.length, 1);
await editor.locator('[data-composer-chip="workdsh-library"]').click();
await expect(page.getByText('内容用于对话引用', { exact: false }).first()).toBeVisible();
await page.screenshot({ path: join(artifacts, 'expert-library-mcp-handoff.png'), animations: 'disabled' });
pass('Expert handoff preserves clickable Library chips, target task selection and MCP selection');
await page.getByRole('button', { name: '连接器:浮层 MCP 验收', exact: true }).click();
await connectors.getByRole('button', { name: '从本次对话移除 浮层 MCP 验收', exact: true }).click();
await expect(useConnector).toHaveText('使用');
pass('MCP popover selects and removes a real local connector, with Escape dismissal');
Expand Down
10 changes: 10 additions & 0 deletions apps/web/tests/integration/connector-member-manager.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ import * as Domain from '@deepseek-ai/dsh-storage-domain';
import { AccessManager, SessionAccessBridge } from '../../../../packages/plugins/access/dist/index.js';
import { AuditJournal } from '../../../../packages/plugins/audit/dist/index.js';
import { ConnectorManager } from '../../../../packages/plugins/connectors/dist/index.js';
import { connectorProcess } from '../../../../packages/plugins/connectors/dist/manager.js';

test('bundled example survives package relocation without rewriting independent MCP commands', async () => {
const stale = {id:'workdsh-example',serverName:'workdsh-example',transport:'stdio',command:'/removed/old-package/node',args:['/removed/old-package/example-server.mjs']};
const resolved=connectorProcess(stale);
assert.equal(resolved.command,process.execPath);
const {access}=await import('node:fs/promises');await access(resolved.args[0]);
assert.deepEqual(connectorProcess({...stale,id:'custom-example'}),{command:stale.command,args:stale.args});
assert.deepEqual(connectorProcess({...stale,args:['/custom/company-server.mjs']}),{command:stale.command,args:['/custom/company-server.mjs']});
});

test('fixed-member connectors authorize native empty Sessions and deny foreign or revoked selections',async()=>{
const root=await mkdtemp(join(tmpdir(),'member-connectors-'));const ctx=new Context();let foreignTouches=0;let active=true;
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
"./enterprise": {
"types": "./dist/enterprise.d.ts",
"default": "./dist/enterprise.js"
},
"./composer": {
"types": "./dist/composer.d.ts",
"default": "./dist/composer.js"
}
},
"files": [
Expand Down
20 changes: 20 additions & 0 deletions packages/contracts/src/composer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** Public draft handoff between independently owned composer features. */
export interface ComposerReference {
source: string;
ref: string;
readonly label: string;
readonly appearance?: 'session' | 'file' | 'folder';
readonly clipboardText: string;
readonly offset: number;
readonly length: number;
}
export interface ComposerHandoffSource {
readonly sessionId: string;
readonly draft: string;
readonly references: readonly ComposerReference[];
}
export interface ComposerHandoff {
readonly sourceSessionId: string;
readonly targetSessionId: string;
readonly references: ComposerReference[];
}
5 changes: 3 additions & 2 deletions packages/plugins/connectors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"locale/*.json"
],
"scripts": {
"build": "node ../../../apps/web/node_modules/pnpm/bin/pnpm.cjs --dir ../../../apps/web --filter workdsh-ui build && node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node ../../../apps/web/scripts/build-connectors.mjs",
"build": "node ../../../apps/web/node_modules/pnpm/bin/pnpm.cjs --dir ../../../apps/web --filter workdsh-contracts build && node ../../../apps/web/node_modules/pnpm/bin/pnpm.cjs --dir ../../../apps/web --filter workdsh-ui build && node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node ../../../apps/web/scripts/build-connectors.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
Expand Down Expand Up @@ -64,7 +64,8 @@
"react": "19.2.4",
"workdsh-ui": "workspace:*",
"zod": "^4.3.6",
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2"
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
"workdsh-contracts": "workspace:*"
},
"dsh": {
"bundle": {
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/connectors/src/client.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ComposerHandoff, ComposerHandoffSource } from 'workdsh-contracts/composer';
declare module '@deepseek-ai/cordis' { interface Events { 'workdsh/composer-handoff'(handoff: ComposerHandoff): Promise<void>; } }
import type { Context } from '@deepseek-ai/cordis';
import type {} from '@deepseek-ai/dsh-client-connection/client';
import type {} from '@deepseek-ai/dsh-client-ui-layout/client';
Expand All @@ -15,6 +17,10 @@ export function apply(ctx: Context): void {
const lifetime = new AbortController();
ctx.effect(() => () => lifetime.abort(), 'workdsh.connectors.client');
const management = createConnectorManagementClient(ctx, lifetime.signal);
ctx.on('workdsh/composer-handoff', async handoff => {
const selected = await management.selection(handoff.sourceSessionId);
await management.setSelection(handoff.targetSessionId, selected);
});
const openCapability = (key: string) => ctx.layout.selectPanel(key as Parameters<typeof ctx.layout.selectPanel>[0]);
const hasCapability = (key: string) => ctx.slots.entriesOfSlot('main').some(entry => entry.options.key === key);
ctx.slots.inject('main', () => ctx.slots.register({
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/connectors/src/connection-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ export function registerConnectorManagementConnection(ctx: Context): void {
}
return Response.json(fail('connector/invalid-request', '连接器管理请求无效。'), { status: 400 });
} catch (cause) {
const known = new Set(['connector/not-found', 'connector/command-required', 'connector/url-required', 'connector/server-name-conflict']);
const known = new Set(['connector/not-found', 'connector/not-ready', 'connector/command-required', 'connector/url-required', 'connector/server-name-conflict']);
const code = cause instanceof Error && known.has(cause.message) ? cause.message : 'connector/internal';
const messages: Record<string, string> = { 'connector/not-found': '未找到该连接器。', 'connector/command-required': 'stdio 连接需要填写启动命令。', 'connector/url-required': 'HTTP 连接需要填写 MCP URL。', 'connector/server-name-conflict': '服务标识已被其他 MCP 使用。' };
const message = messages[code] ?? '连接器操作失败,请重试。';
const message = code === 'connector/not-ready' ? '所选 MCP 尚未连接,请在连接器中检查运行状态后重试。' : messages[code] ?? '连接器操作失败,请重试。';
return Response.json(fail(code, message));
}
},
Expand Down
11 changes: 10 additions & 1 deletion packages/plugins/connectors/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import { connectorDefinitionSchema, connectorSelectionsDomainSpec, connectorsDom
declare module '@deepseek-ai/cordis' { interface Context { workdshConnectors: ConnectorManagementService; } }

const serverPath = fileURLToPath(new URL('./example-server.mjs', import.meta.url));
/** The bundled example belongs to the running package, not its installation path. */
export function connectorProcess(definition: Pick<ConnectorDefinition, 'id' | 'serverName' | 'transport' | 'command' | 'args' | 'authorizationCredentialRef'>) {
const bundledExample = definition.id === 'workdsh-example' && definition.serverName === 'workdsh-example'
&& definition.transport === 'stdio' && !definition.authorizationCredentialRef
&& definition.args?.length === 1 && /(?:^|[\\/])example-server\.mjs$/.test(definition.args[0]!)
&& /(?:^|[\\/])node(?:\.exe)?$/.test(definition.command ?? '');
return bundledExample ? { command: process.execPath, args: [serverPath] }
: { command: definition.command!, args: [...(definition.args ?? [])] };
}
type ChildFiber = { dispose(): Promise<void> };
type Runtime = { child?: ChildFiber; state: ConnectorState; diagnostic?: string };
type JsonRecord = Record<string, unknown>;
Expand Down Expand Up @@ -158,7 +167,7 @@ export class ConnectorManager extends Service implements ConnectorManagementServ
}
const headers: Record<string, string> = authorization ? { Authorization: authorization.value } : {};
const transportConfig = definition.transport === 'stdio'
? { serverName: definition.serverName, transport: 'stdio' as const, command: definition.command!, args: [...(definition.args ?? [])], env: {} }
? { serverName: definition.serverName, transport: 'stdio' as const, ...connectorProcess(definition), env: {} }
: { serverName: definition.serverName, transport: 'streamable-http' as const, url: definition.url!, headers };
const fiber = this.ctx.plugin(McpClient, { ...transportConfig, failOnStartupError: true, toolCallTimeoutMs: 10_000, maxInstructionBytes: 8_192,
reconnect: { enabled: true, initialDelayMs: 250, maxDelayMs: 5_000, maxAttempts: 4 } }) as ChildFiber & PromiseLike<unknown>;
Expand Down
22 changes: 19 additions & 3 deletions packages/plugins/experts/src/client.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ComposerHandoff, ComposerHandoffSource } from 'workdsh-contracts/composer';
declare module '@deepseek-ai/cordis' { interface Events { 'workdsh/composer-handoff'(handoff: ComposerHandoff): Promise<void>; } }
import type { ActivityPresentation } from 'workdsh-contracts/activity';
declare module '@deepseek-ai/cordis' { interface Context { activityPresentation: ActivityPresentation; } }
import { installExpertPresetMenu } from './client/PresetMenu.js';
Expand All @@ -19,7 +21,7 @@ import { PendingExpertDraft, pendingExpertDraftKey, pendingExpertDraftEvent, exp
import { createExpertManagementClient } from './client/management.js';

export const name = 'workdsh-experts-client';
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'remote', 'remote.session', 'connection', 'uiWorkspace'];
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'remote', 'remote.session', 'connection', 'uiWorkspace', 'conversation'];

type SessionId = Awaited<ReturnType<ISessions['create']>>;

Expand Down Expand Up @@ -105,7 +107,7 @@ export function apply(ctx: Context): void {
throw new Error('未能打开专家任务,请稍后在会话列表中查看。');
};

const summon = async (expertId: string, revisionId: string | undefined, draftText: string | undefined): Promise<void> => {
const summon = async (expertId: string, revisionId: string | undefined, draftText: string | undefined, source?: ComposerHandoffSource): Promise<void> => {
lifetime.signal.throwIfAborted();
const workspace = resolveWorkspace();
const plan = await management.prepareExecution(expertId, {
Expand All @@ -118,11 +120,25 @@ export function apply(ctx: Context): void {
}
const creation = await management.createExecution(plan.executionPlanId, management.newOperationId('create-execution'));
const sessionId = creation.sessionId as SessionId;
const references = source?.references.map(reference => ({ ...reference })) ?? [];
if (source) await ctx.parallel('workdsh/composer-handoff', { sourceSessionId: source.sessionId, targetSessionId: String(sessionId), references });
await openSession(sessionId);
ctx.layout.selectPanel(null);
if (creation.handoffId) {
const handoff = await management.consumeHandoff(creation.handoffId, creation.handoffId);
if (handoff.text) await seedDraft(sessionId, handoff.text);
if (!source && handoff.text) await seedDraft(sessionId, handoff.text);
}
if (source) {
const binding = sessions.binding(sessionId);
if (!binding) throw new Error('专家任务输入框尚未就绪,请重试。');
const input = ctx.conversation.input.for(binding.ctx);
const state = input.state.getSnapshot();
if (state.draft || state.attachmentIds.length || state.phase !== 'plain') throw new Error('专家任务已有草稿,不能覆盖。');
input.setDraft(source.draft);
for (const reference of [...references].sort((a, b) => b.offset - a.offset)) {
const current = input.state.getSnapshot();
if (!input.insertReference(reference, { start: reference.offset, end: reference.offset + reference.length, draftRev: current.draftRev })) throw new Error('资料引用未能恢复,请在新任务中重新添加。');
}
}
};

Expand Down
Loading
Loading