diff --git a/apps/web/scripts/probe-expert-conversation-entry.mjs b/apps/web/scripts/probe-expert-conversation-entry.mjs new file mode 100644 index 0000000000..68e51db6f1 --- /dev/null +++ b/apps/web/scripts/probe-expert-conversation-entry.mjs @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { spawn, execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, writeFile, realpath, copyFile, unlink, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { createRequire } from 'node:module'; +import { randomUUID, createHash } from 'node:crypto'; +import { chromium, expect } from '@playwright/test'; +import { checkProfessionalSession } from './check-expert-professional-session.mjs'; +import { expertDraftUrl } from '../../../packages/plugins/experts/dist/domain/navigation.js'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const scenario = process.argv[2] || 'normal'; +assert.ok(['normal', 'incomplete', 'dirty'].includes(scenario), 'Scenario must be normal, incomplete or dirty'); +const runLabel = process.argv[3] || ''; +const confirmed=process.argv[4]==='--confirmed'; +assert.ok(!process.argv[4]||confirmed,'Only --confirmed is supported'); +assert.ok(!confirmed||scenario==='dirty','Confirmed scope applies only to dirty fixture'); +assert.ok(!runLabel || /^[a-z0-9-]{1,40}$/.test(runLabel), 'Run label must contain lowercase letters, digits or hyphens'); +const artifacts = join(root, '.artifacts/expert-conversation-entry'); +const home = await realpath(await mkdtemp(join(tmpdir(), 'workdsh-experts-professional-'))); +await mkdir(artifacts, { recursive: true }); +await unlink(join(artifacts, 'report.json')).catch(() => {}); +const workspace = join(home, 'workspace'); +await mkdir(workspace); +const fixtures = join(root, 'tests/fixtures/experts/retail-analysis'); +const skillRoot = join(home, 'agents/skills/retail-analysis-acceptance'); +await mkdir(skillRoot, { recursive: true }); +await copyFile(join(fixtures, 'SKILL.md'), join(skillRoot, 'SKILL.md')); +await copyFile(join(fixtures, `${scenario}.csv`), join(workspace, 'input.csv')); +const inputHash = createHash('sha256').update(await readFile(join(workspace, 'input.csv'))).digest('hex'); +await mkdir(join(home, 'storages')); +const workspaceId = randomUUID(); +const now = new Date().toISOString(); +await writeFile(join(home, 'storages/workspace.json'), JSON.stringify({ + unit: { name: 'workspace', version: 2 }, + global: { initialized: true, workspaceIds: [workspaceId], archivedSessionIds: [] }, + tables: { workspaces: { [workspaceId]: { path: workspace, title: 'Expert test', sessionIds: [], createdAt: now, updatedAt: now } } }, +})); +const env = { ...process.env, DSH_HOME: home, DSH_AGENTS_HOME: join(home, 'agents'), PATH: `${join(root, 'node_modules/.bin')}:${dirname(process.execPath)}:${process.env.PATH}` }; +const dsh = join(root, 'node_modules/@deepseek-ai/dsh/lib/bin.js'); +const pnpm = join(root, 'node_modules/pnpm/bin/pnpm.cjs'); +const exec = promisify(execFile); +const command = async (bin, args, cwd = home) => (await exec(process.execPath, [bin, ...args], { cwd, env, timeout: 60_000, maxBuffer: 8 * 1024 * 1024 })).stdout; +const cli = (...args) => command(dsh, args); +let server, browser, log = ''; +const browserErrors = []; +const checks = []; +const pass = text => { checks.push(text); console.log(`PASS: ${text}`); }; +async function start() { + log = ''; + server = spawn(process.execPath, [dsh, '--profile', 'experts', '--host', '127.0.0.1', '--port', '0', '--no-open'], { cwd: home, env, stdio: ['ignore', 'pipe', 'pipe'] }); + server.stdout.on('data', value => { log += value; }); + server.stderr.on('data', value => { log += value; }); + const deadline = Date.now() + 25_000; + while (!/http:\/\/127\.0\.0\.1:\d+\/\?token=[\w-]+/.test(log)) { + if (server.exitCode !== null || Date.now() > deadline) throw new Error(`Host startup failed: ${log.replace(/token=[^\s]+/g, 'token=[redacted]').slice(-2500)}`); + await new Promise(resolve => setTimeout(resolve, 100)); + } + const loginUrl = log.match(/http:\/\/127\.0\.0\.1:\d+\/\?token=[\w-]+/)[0]; + let response; + while (!response) { + try { response = await fetch(loginUrl, { redirect: 'manual', signal: AbortSignal.timeout(2000) }); } + catch { + if (server.exitCode !== null || Date.now() > deadline) throw new Error(`Host unavailable: ${log.replace(/token=[^\s]+/g, 'token=[redacted]').slice(-4500)}`); + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + const cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; '); + assert.ok(cookie); + return { address: new URL(loginUrl).origin, cookie }; +} +async function stop() { + if (!server || server.exitCode !== null || server.signalCode !== null) return; + const stopped = new Promise(resolve => server.once('close', resolve)); + server.kill('SIGTERM'); + const timer = setTimeout(() => server.kill('SIGKILL'), 3000); + await stopped; clearTimeout(timer); +} +async function api(host, endpoint, payload = {}) { + const response = await fetch(`${host.address}/api/workdsh-experts`, { method: 'POST', headers: { cookie: host.cookie, 'content-type': 'application/json' }, body: JSON.stringify({ endpoint, payload }), signal: AbortSignal.timeout(15_000) }); + assert.equal(response.status, 200); + const result = await response.json(); + assert.equal(result.ok, true, JSON.stringify(result)); + return result.value; +} +let credential; +try { + const tarballs = []; + for (const directory of ['../../packages/providers/identity-local', '../../packages/plugins/audit', '../../packages/plugins/access', '../../packages/plugins/skills', '../../packages/plugins/experts', '../../packages/plugins/library', '../../packages/plugins/connectors', '../../packages/bundle']) { + const manifest = JSON.parse(await readFile(join(root, directory, 'package.json'), 'utf8')); + await command(pnpm, ['--filter', manifest.name, 'pack', '--pack-destination', artifacts], root); + tarballs.push(join(artifacts, `${manifest.name}-${manifest.version}.tgz`)); + } + await cli('--profile', 'experts', '--from-default-profile', 'web', '--dump-config'); + await cli('plugin', '--profile', 'experts', 'add', ...tarballs, '--offline'); + const host = await start(); + pass('Eight independent installed packages use a temporary Home and synthetic workspace'); + const drafted = await api(host, 'create-draft', { operationId: 'professional-create', definition: { + name: '门店经营分析验收专家', description: '核对合成门店数据,拆解指标变化,交付可审计报告。', + role: '你是业务数据分析专家。用实际计算核对金额、转化与门店贡献;不虚构行业履历或数据。', + methodology: '先检查数据完整性与计价单位,再计算对比和门店贡献,交叉核对;区分算术驱动、事实和待验证假设。', + boundaries: '仅操作当前合成工作区。原始 input.csv 不可修改。不得联网、安装依赖、读取凭据或其他用户文件。', + deliverables: '生成 analysis-results.json 和 analysis-report.md,给出可核验指标、局限及后续行动。', + tags: ['经营分析', '合成数据'], examples: [{ id: 'retail', title: '月度经营诊断', prompt: (await readFile(join(fixtures, 'prompt.md'), 'utf8')).trim() }], + skillRequirements: [{ name: 'retail-analysis-acceptance', skillId: 'retail-analysis-acceptance' }], futureRequirements: [], + } }); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + page.on('pageerror', error => browserErrors.push(error.message)); + await page.context().addCookies(host.cookie.split('; ').map(pair => { const at = pair.indexOf('='); return { name: pair.slice(0, at), value: pair.slice(at + 1), url: host.address }; })); + await page.goto(`${host.address}/${expertDraftUrl(drafted.expertId)}`); + for (const name of ['Continue', 'Configure later']) await page.getByRole('button', { name, exact: true }).click({ timeout: 4000 }).catch(() => {}); + await expect(page.getByRole('dialog', { name: '编辑专家草稿', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '发布', exact: true }).click(); + await page.getByRole('button', { name: '确认发布此版本', exact: true }).click(); + await expect(page.getByRole('dialog', { name: '发布成功', exact: true })).toBeVisible(); + const published = await api(host, 'get', { expertId: drafted.expertId }); + assert.equal(published.revision.dependencyLock.length, 1); + await page.getByRole('button', { name: '去试试', exact: true }).click(); + const editor = page.locator('[contenteditable="true"]').first(); + await expect(editor).toBeVisible(); + await editor.fill('EXPERT-COMPOSER-DRAFT-验收:请分析本地数据'); + await expect(page.getByRole('button', { name: '召唤专家', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '召唤专家', exact: true }).click(); + const picker = page.getByRole('dialog', { name: '召唤专家', exact: true }); + await expect(picker).toBeVisible(); + const choice = picker.getByRole('button', { name: /门店经营分析验收专家/ }); + await expect(choice).toBeEnabled(); + await page.screenshot({ path: join(artifacts, 'picker.png'), fullPage: true, animations: 'disabled' }); + await page.keyboard.press('Escape'); + await expect(editor).toHaveText('EXPERT-COMPOSER-DRAFT-验收:请分析本地数据'); + pass('Cancel preserves the current conversation draft'); + await page.getByRole('button', { name: '召唤专家', exact: true }).click(); + const createdResponse = page.waitForResponse(response => response.url().endsWith('/api/workdsh-experts') && response.request().postDataJSON()?.endpoint === 'create-execution'); + await choice.click(); + const creation = (await (await createdResponse).json()).value; + const binding = await api(host, 'verify-binding', { sessionId: creation.sessionId }); + assert.equal(binding.expertRevisionRef.expertId, drafted.expertId); + await expect(editor).toHaveText('EXPERT-COMPOSER-DRAFT-验收:请分析本地数据'); + await expect(picker).toHaveCount(0); + await page.screenshot({ path: join(artifacts, 'summoned.png'), fullPage: true }); + pass('Conversation summon creates a bound native Session and transfers the draft without sending'); + await page.locator('button').filter({ hasText: 'New Session' }).first().click(); + await expect(page.getByRole('button', { name: '召唤专家', exact: true })).toBeVisible(); + await editor.fill('NEW-SESSION-EXPERT-DRAFT'); + await page.getByRole('button', { name: '召唤专家', exact: true }).click(); + await expect(choice).toBeEnabled(); + await choice.click(); + await expect(editor).toHaveText('NEW-SESSION-EXPERT-DRAFT'); + pass('New conversation composer also summons experts and preserves its draft'); + await page.reload(); + for (const name of ['Continue', 'Configure later']) await page.getByRole('button', { name, exact: true }).click({ timeout: 4000 }).catch(() => {}); + await expect(page.getByRole('button', { name: '召唤专家', exact: true })).toBeVisible(); + pass('Expert conversation entry survives page reload'); + const featureApi = async (feature, endpoint, payload = {}) => { + const response = await fetch(`${host.address}/api/workdsh-${feature}`, { method: 'POST', headers: { cookie: host.cookie, 'content-type': 'application/json' }, body: JSON.stringify({ endpoint, payload }) }); + const result = await response.json(); assert.equal(result.ok, true, JSON.stringify(result)); return result.value; + }; + const folder = await featureApi('library', 'create-folder', { name: '浮层验收资料' }); + await featureApi('library', 'import', { parentId: folder.id, name: '浮层体验验收.md', mediaType: 'text/markdown', base64: Buffer.from('# 浮层体验验收\n内容用于对话引用').toString('base64'), operationId: 'composer-library-import' }); + await editor.fill('保留原草稿'); + const libraryButton = page.getByRole('button', { name: '从资料库添加到对话', exact: true }); + await libraryButton.click(); + const libraryPicker = page.getByRole('dialog', { name: '资料库', exact: true }); + await expect(libraryPicker).toBeVisible(); + await libraryPicker.getByRole('button', { name: '打开 浮层验收资料', exact: true }).click(); + await expect(libraryPicker.getByRole('button', { name: '添加 浮层体验验收.md', exact: true })).toBeEnabled(); + await page.screenshot({ path: join(artifacts, 'library-picker.png'), animations: 'disabled' }); + await page.keyboard.press('Escape'); + await expect(libraryPicker).toHaveCount(0); + await expect(editor).toHaveText('保留原草稿'); + await libraryButton.click(); + await libraryPicker.getByRole('button', { name: '打开 浮层验收资料', exact: true }).click(); + await libraryPicker.getByRole('button', { name: '添加 浮层体验验收.md', exact: true }).click(); + await expect(libraryPicker).toHaveCount(0); + await expect(editor).toContainText('保留原草稿'); + await expect(editor).toContainText('浮层体验验收.md'); + pass('Library popover browses folders, cancels without mutation and adds a native reference preserving text'); + await libraryButton.click(); + await libraryPicker.getByRole('button', { name: '管理资料库', exact: true }).click(); + await expect(page.getByRole('heading', { name: '资料库', exact: true })).toBeVisible(); + pass('Library management footer opens its existing page'); + await page.locator('button').filter({ hasText: 'New Session' }).first().click(); + await featureApi('connectors', 'create', { title: '浮层 MCP 验收', description: '连接器选择体验验收', serverName: 'composer-acceptance', transport: 'stdio', command: process.execPath, args: [join(root, '../../packages/plugins/connectors/dist/example-server.mjs')] }); + const connectorButton = page.getByRole('button', { name: '连接器', exact: true }); + await connectorButton.click(); + const connectors = page.getByRole('menu', { name: '连接器', exact: true }); + const useConnector = connectors.getByRole('button', { name: '用于本次对话 浮层 MCP 验收', exact: true }); + await expect(useConnector).toBeVisible(); + await page.screenshot({ path: join(artifacts, 'connector-picker.png'), animations: 'disabled' }); + await page.keyboard.press('Escape'); + await expect(connectors).toHaveCount(0); + await connectorButton.click(); + await useConnector.click(); + await expect(connectors.getByRole('button', { name: '从本次对话移除 浮层 MCP 验收', exact: true })).toHaveText('已选', { timeout: 20000 }); + 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'); + await connectors.getByRole('button', { name: '管理连接器', exact: true }).click(); + await expect(connectors).toHaveCount(0); + pass('MCP management footer dismisses the picker and opens management'); + assert.deepEqual(browserErrors, []); + await writeFile(join(artifacts, 'report.json'), JSON.stringify({ checks, sessionId: creation.sessionId, home }, null, 2)); +} catch (error) { + const page = browser?.contexts()[0]?.pages()[0]; + if (page) { await page.screenshot({ path: join(artifacts, 'failure.png'), fullPage: true }); await writeFile(join(artifacts, 'failure-text.txt'), await page.locator('body').innerText()); } + console.error(String(error.message).replace(/token=[^\s]+/g, 'token=[redacted]').slice(0, 1800)); + process.exitCode = 1; +} finally { + await browser?.close(); await stop(); + await writeFile(join(artifacts, 'host.log'), log.replace(/token=[^\s]+/g, 'token=[redacted]')); +} diff --git a/packages/plugins/connectors/src/client/ConnectorPicker.tsx b/packages/plugins/connectors/src/client/ConnectorPicker.tsx index 6e20651826..6d2207e9ac 100644 --- a/packages/plugins/connectors/src/client/ConnectorPicker.tsx +++ b/packages/plugins/connectors/src/client/ConnectorPicker.tsx @@ -7,7 +7,7 @@ import type { ConnectorManagementClient } from './management.js'; const labels = { ready: '已连接', discovering: '连接中', offline: '连接异常', disabled: '未连接' } as const; const css = `${modalCss} -.wd-connector-picker{position:relative;display:flex;align-items:center}.wd-connector-trigger{display:flex;align-items:center;gap:7px;max-width:190px;height:36px;padding:0 9px;border:0;border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;cursor:pointer}.wd-connector-trigger:hover,.wd-connector-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.wd-connector-trigger .trigger-icon{display:grid;place-items:center;width:22px;height:22px;flex:none;border-radius:7px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.wd-connector-trigger .trigger-icon.connected{background:linear-gradient(145deg,#146c7c,#11b7c8);color:white;font-weight:700}.wd-connector-trigger .trigger-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}.wd-connector-trigger .trigger-count{color:var(--dsw-alias-label-secondary);font-size:11px} +.wd-connector-picker{position:relative;display:flex;align-items:center}.wd-connector-trigger{display:flex;align-items:center;gap:7px;max-width:190px;height:36px;padding:0 9px;border:0;border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;cursor:pointer}.wd-connector-trigger:hover,.wd-connector-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.wd-connector-trigger .trigger-icon{display:grid;place-items:center;width:22px;height:22px;flex:none;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary)}.wd-connector-trigger .trigger-icon.connected{background:linear-gradient(145deg,#146c7c,#11b7c8);color:white;font-weight:700}.wd-connector-trigger .trigger-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}.wd-connector-trigger .trigger-count{color:var(--dsw-alias-label-secondary);font-size:11px} .wd-connector-popover{position:absolute;z-index:90;left:0;bottom:calc(100% + 10px);width:336px;max-height:min(430px,60vh);overflow:auto;padding:8px;border:0;border-radius:15px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel);font:14px/20px "PingFang SC","Microsoft YaHei",sans-serif}.wd-connector-popover *{box-sizing:border-box}.wd-connector-popover .picker-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;width:100%;min-height:54px;padding:5px;border-radius:10px}.wd-connector-popover .picker-row:hover{background:var(--dsw-alias-interactive-bg-hover)}.wd-connector-popover .picker-main{display:grid;grid-template-columns:38px minmax(0,1fr);align-items:center;gap:10px;min-width:0;padding:3px;border:0;background:transparent;color:inherit;text-align:left;cursor:pointer}.wd-connector-popover .picker-mark,.connector-picker-detail .picker-mark{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:linear-gradient(145deg,#146c7c,#11b7c8);color:#fff;font-weight:700;text-transform:uppercase}.wd-connector-popover .picker-copy{min-width:0}.wd-connector-popover .picker-copy strong,.wd-connector-popover .picker-copy small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wd-connector-popover .picker-copy strong{font-size:14px;font-weight:600}.wd-connector-popover .picker-copy small{margin-top:2px;color:var(--dsw-alias-label-secondary);font-size:11px}.wd-connector-popover .picker-action{min-width:52px;padding:6px 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}.wd-connector-popover .picker-action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.wd-connector-popover .picker-action.ready{color:var(--dsw-alias-label-primary)}.wd-connector-popover .picker-action.offline{color:var(--dsw-alias-state-danger-primary,var(--dsw-alias-label-primary))}.wd-connector-popover .picker-action.discovering{color:var(--dsw-alias-label-secondary)}.wd-connector-popover .picker-empty{padding:28px 12px;text-align:center;color:var(--dsw-alias-label-secondary)}.wd-connector-popover .manage{display:flex;align-items:center;gap:8px;width:100%;margin-top:6px;padding:10px 9px 5px;border:0;border-top:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer}.wd-connector-popover .manage:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)} .wd-dialog.connector-picker-detail{width:min(620px,calc(100vw - 48px));padding:26px}.connector-picker-detail .picker-detail-hero{display:grid;grid-template-columns:52px minmax(0,1fr);gap:15px;align-items:center;padding-right:44px}.connector-picker-detail .picker-mark{width:52px;height:52px;border-radius:14px;font-size:21px}.connector-picker-detail h2{margin:0 0 3px;font-size:21px}.connector-picker-detail .detail-state{color:var(--dsw-alias-label-secondary);font-size:12px}.connector-picker-detail .detail-description{margin:20px 0;color:var(--dsw-alias-label-secondary);line-height:1.7}.connector-picker-detail .detail-tools{max-height:210px;overflow:auto;padding:15px;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:var(--dsw-alias-bg-layer-2)}.connector-picker-detail .detail-tools strong{display:block;margin-bottom:8px}.connector-picker-detail code{display:block;margin:5px 0;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);font:12px/18px ui-monospace,SFMono-Regular,Menlo,monospace}.connector-picker-detail .detail-error{margin:12px 0 0;color:var(--dsw-alias-state-danger-primary,var(--dsw-alias-label-primary));font-size:12px}.connector-picker-detail .detail-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:18px}.connector-picker-detail .detail-actions button{min-height:38px;padding:7px 14px;border:1px solid var(--dsw-alias-interactive-bg-hover);border-radius:9px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);cursor:pointer}.connector-picker-detail .detail-actions .primary{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-label-primary-inverted);font-weight:600} @media(max-width:560px){.wd-connector-popover{position:fixed;left:12px;right:12px;bottom:86px;width:auto}.wd-connector-trigger .trigger-name{display:none}} @@ -22,12 +22,15 @@ export function ConnectorPicker({ management, openManagement, sessionId }: Props const [selectedIds, setSelectedIds] = useState([]); const [detail, setDetail] = useState(); const [changing, setChanging] = useState(); + const [error, setError] = useState(''); const refresh = useCallback(async () => { + try { const [nextRows, nextSelection] = await Promise.all([ - management.list().catch(() => []), - management.selection(String(sessionId)).catch(() => []), + management.list(), + management.selection(String(sessionId)), ]); - setRows(nextRows); setSelectedIds(nextSelection); + setRows(nextRows); setSelectedIds(nextSelection); setError(''); + } catch (cause) { setError(cause instanceof Error ? cause.message : '无法加载连接器,请重试。'); } }, [management, sessionId]); useEffect(() => { // Missing persisted selection already means none; opening an existing blank @@ -42,8 +45,9 @@ export function ConnectorPicker({ management, openManagement, sessionId }: Props useEffect(() => { if (!open) return; const close = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); }; - document.addEventListener('pointerdown', close); - return () => document.removeEventListener('pointerdown', close); + const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setOpen(false); root.current?.querySelector('.wd-connector-trigger')?.focus(); } }; + document.addEventListener('pointerdown', close); document.addEventListener('keydown', escape); + return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', escape); }; }, [open]); const selected = rows.filter(row => selectedIds.includes(row.id)); @@ -54,14 +58,14 @@ export function ConnectorPicker({ management, openManagement, sessionId }: Props try { const next = selectedIds.includes(row.id) ? selectedIds.filter(id => id !== row.id) : [...selectedIds, row.id]; setSelectedIds(await management.setSelection(String(sessionId), next)); - } finally { setChanging(undefined); } + } catch (cause) { setError(cause instanceof Error ? cause.message : '无法修改连接器选择,请重试。'); } finally { setChanging(undefined); } }; return <>
@@ -73,7 +77,8 @@ export function ConnectorPicker({ management, openManagement, sessionId }: Props
)} - {!rows.length ?
尚未添加连接器
: null} + {error ?
{error}
: null} + {!rows.length && !error ?
尚未添加连接器
: null} : null} diff --git a/packages/plugins/experts/src/client.tsx b/packages/plugins/experts/src/client.tsx index 59ce63a076..72721c7af2 100644 --- a/packages/plugins/experts/src/client.tsx +++ b/packages/plugins/experts/src/client.tsx @@ -1,6 +1,7 @@ import type { ActivityPresentation } from 'workdsh-contracts/activity'; declare module '@deepseek-ai/cordis' { interface Context { activityPresentation: ActivityPresentation; } } import { installExpertPresetMenu } from './client/PresetMenu.js'; +import { ConversationExperts } from './client/ConversationExperts.js'; import { parseDocument } from 'yaml'; import type { Context } from '@deepseek-ai/cordis'; import type {} from '@deepseek-ai/dsh-client-connection/client'; @@ -173,4 +174,5 @@ export function apply(ctx: Context): void { inject: () => ({ toggleNavigation: () => ctx.layout.toggleSidebar(), management, openCapability, hasCapability, summon, createExpertTask, editExpertTask }), }, ExpertsPanel)); ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({ name: 'conversation.input.overlay', id: 'workdsh-expert-draft' }, PendingExpertDraft)); + ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'workdsh-summon-expert', order: 25, inject: () => ({ management, summon, openManagement: () => openCapability('workdsh-experts') }) }, ConversationExperts)); } diff --git a/packages/plugins/experts/src/client/ConversationExperts.tsx b/packages/plugins/experts/src/client/ConversationExperts.tsx new file mode 100644 index 0000000000..54ad6ade38 --- /dev/null +++ b/packages/plugins/experts/src/client/ConversationExperts.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'; +import type { ExpertSummary } from '../shared.js'; +import type { ExpertManagementClient } from './management.js'; +import { Icon } from 'workdsh-ui'; + +const pickerCss = ` +.wd-expert-picker{position:relative;display:flex;align-items:center} +.wd-expert-trigger{display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:0;border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer} +.wd-expert-trigger:hover,.wd-expert-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)} +.wd-expert-trigger:disabled{opacity:.5;cursor:default} +.wd-expert-popover{position:absolute;z-index:90;left:0;bottom:calc(100% + 10px);width:336px;max-height:min(430px,60vh);overflow:auto;padding:8px;border-radius:15px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel);font:14px/20px "PingFang SC","Microsoft YaHei",sans-serif} +.wd-expert-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 5px;border-radius:10px}.wd-expert-row:hover{background:var(--dsw-alias-interactive-bg-hover)} +.wd-expert-copy{display:grid;grid-template-columns:38px minmax(0,1fr);gap:10px;align-items:center;min-width:0} +.wd-expert-mark{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:var(--dsw-alias-interactive-bg-hover)} +.wd-expert-copy strong,.wd-expert-copy small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wd-expert-copy small{margin-top:2px;font-size:11px;color:var(--dsw-alias-label-secondary)} +.wd-expert-action,.wd-expert-manage{border:0;background:transparent;color:inherit;cursor:pointer;font:inherit;border-radius:8px;padding:6px 8px}.wd-expert-action:disabled{opacity:.5;cursor:default} +.wd-expert-manage{display:flex;gap:8px;width:100%;margin-top:6px;padding:10px 9px;border-top:1px solid var(--dsw-alias-border-l2);border-radius:0;text-align:left}.wd-expert-manage:hover{background:var(--dsw-alias-interactive-bg-hover)} +.wd-expert-status{padding:12px;color:var(--dsw-alias-label-secondary)} +@media(max-width:560px){.wd-expert-popover{position:fixed;left:12px;right:12px;bottom:86px;width:auto}} +`; + +type Props = PropsRuntime<'conversation.input.left'> & { + management: ExpertManagementClient; + openManagement: () => void; + summon: (id: string, revision: string | undefined, draft: string | undefined) => Promise; +}; + +/** Use the governed execution flow, never select an internal preset directly. */ +export function ConversationExperts({ useInput, management, summon, openManagement }: Props) { + const input = useInput(state => state); + const root = React.useRef(null); + const trigger = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [items, setItems] = React.useState([]); + const [error, setError] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [acting, setActing] = React.useState(false); + React.useEffect(() => { + if (!open || acting) return; + const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); }; + const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setOpen(false); trigger.current?.focus(); } }; + document.addEventListener('pointerdown', outside); document.addEventListener('keydown', escape); + return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape); }; + }, [open, acting]); + React.useEffect(() => { + if (!open) return; + const controller = new AbortController(); + setLoading(true); setError(''); setItems([]); + void (async () => { + const rows: ExpertSummary[] = []; + let cursor: string | undefined; + do { + const page = await management.list({ availability: 'enabled', limit: 100, ...(cursor ? { cursor } : {}) }, controller.signal); + rows.push(...page.items); cursor = page.nextCursor; + } while (cursor); + if (!controller.signal.aborted) setItems(rows.filter(row => row.canUse && row.publishedRevisionRef)); + })().catch(cause => { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '无法加载专家,请重试。'); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [open, management]); + const choose = async (row: ExpertSummary) => { + setActing(true); setError(''); + try { await summon(row.id, row.publishedRevisionRef?.revisionId, input.draft || undefined); setOpen(false); } + catch (cause) { setError(cause instanceof Error ? cause.message : '召唤失败,请重试。'); } + finally { setActing(false); } + }; + const blocked = input.attachmentIds.length > 0 || input.phase !== 'plain'; + return <> + +
+ + {open &&
+ {loading &&
正在加载专家…
} + {error &&
{error}
} + {!loading && !error && !items.length &&
暂无可用专家,请先导入或发布专家。
} + {items.map(row =>
+
{row.name}{row.description}
+ +
)} + +
} +
+ ; +} diff --git a/packages/plugins/library/src/client.tsx b/packages/plugins/library/src/client.tsx index d12b520dad..e4a680ccc8 100644 --- a/packages/plugins/library/src/client.tsx +++ b/packages/plugins/library/src/client.tsx @@ -147,6 +147,6 @@ export function apply(ctx: Context): void { ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'workdsh-library', inject: () => ({ management, previewRegistry, toggleNavigation: () => ctx.layout.toggleSidebar(), startConversation }) }, LibraryPanel)); ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'workdsh-library-picker', order: 35, - inject: () => ({ management, openLibrary: () => ctx.layout.selectPanel('workdsh-library' as Parameters[0]), openPicker: (sessionId: string, draft: string, draftRev: number) => { const binding = sessions.binding(sessionId as never); if (!binding) return; const offset = draft.length; ctx.inputTriggers.sessionOf(binding.ctx).toggleSource('workdsh-library', { trigger: '@', query: '', quoted: false, position: offset === 0 ? 'leading' : 'inline', span: { start: offset, end: offset, draftRev } }); } }), + inject: () => ({ management, openLibrary: () => ctx.layout.selectPanel('workdsh-library' as Parameters[0]), addReference: (sessionId: string, entry: import('workdsh-contracts/library').LibraryTreeEntry) => entry.asset && entry.revision ? insertReference(sessionId, { assetId: entry.asset.id, revisionId: entry.revision.id, nodeId: entry.id, name: entry.name, kind: entry.asset.kind }) : false }), }, LibraryPicker)); } diff --git a/packages/plugins/library/src/client/LibraryPicker.tsx b/packages/plugins/library/src/client/LibraryPicker.tsx index 9ae257c95c..ec512cfd9a 100644 --- a/packages/plugins/library/src/client/LibraryPicker.tsx +++ b/packages/plugins/library/src/client/LibraryPicker.tsx @@ -1,26 +1,76 @@ import * as React from 'react'; -import { useEffect } from 'react'; import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'; import { Icon } from 'workdsh-ui'; +import type { LibraryTreeEntry } from 'workdsh-contracts/library'; import type { LibraryClient } from './management.js'; import { libraryPickerRequestedEvent } from './selection-events.js'; type Props = PropsRuntime<'conversation.input.left'> & { management: LibraryClient; openLibrary: () => void; - openPicker: (sessionId: string, draft: string, draftRev: number) => void; + addReference: (sessionId: string, entry: LibraryTreeEntry) => boolean; }; - -const css = `.wd-library-picker-trigger{display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary,#aaa);font:inherit;cursor:pointer}.wd-library-picker-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.wd-library-picker-trigger .icon{display:grid;place-items:center;width:23px;height:23px;color:currentColor}`; - -export function LibraryPicker({ sessionId, useInput, openPicker }: Props) { - const draft = useInput(state => state.draft); - const draftRev = useInput(state => state.draftRev); - const launch = () => openPicker(String(sessionId), draft, draftRev); - useEffect(() => { - const show = () => launch(); +const css = ` +.wd-library-picker{position:relative;display:flex;align-items:center} +.wd-library-picker-trigger{display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer} +.wd-library-picker-trigger:hover,.wd-library-picker-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)} +.wd-library-popover{position:absolute;z-index:90;left:0;bottom:calc(100% + 10px);width:336px;max-height:min(430px,60vh);overflow:auto;padding:8px;border-radius:15px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel);font:14px/20px "PingFang SC","Microsoft YaHei",sans-serif} +.wd-library-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 5px;border-radius:10px}.wd-library-row:hover{background:var(--dsw-alias-interactive-bg-hover)} +.wd-library-main{display:grid;grid-template-columns:38px minmax(0,1fr);gap:10px;align-items:center;min-width:0;border:0;background:transparent;color:inherit;text-align:left;font:inherit;padding:0;cursor:pointer} +.wd-library-mark{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:var(--dsw-alias-interactive-bg-hover)} +.wd-library-copy{min-width:0}.wd-library-copy strong,.wd-library-copy small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wd-library-copy small{margin-top:2px;color:var(--dsw-alias-label-secondary);font-size:11px} +.wd-library-action,.wd-library-manage,.wd-library-back{border:0;background:transparent;color:inherit;cursor:pointer;font:inherit;padding:6px 8px;border-radius:8px}.wd-library-action:disabled{opacity:.5;cursor:default} +.wd-library-manage{display:flex;gap:8px;width:100%;margin-top:6px;padding:10px 9px;border-top:1px solid var(--dsw-alias-border-l2);border-radius:0;text-align:left}.wd-library-manage:hover{background:var(--dsw-alias-interactive-bg-hover)} +.wd-library-status{padding:12px;color:var(--dsw-alias-label-secondary)} +@media(max-width:560px){.wd-library-popover{position:fixed;left:12px;right:12px;bottom:86px;width:auto}} +`; +export function LibraryPicker({ sessionId, management, openLibrary, addReference }: Props) { + const root = React.useRef(null); + const trigger = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [path, setPath] = React.useState([]); + const [rows, setRows] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(''); + const parentId = path.at(-1)?.id; + React.useEffect(() => { + const show = () => { setPath([]); setOpen(true); }; window.addEventListener(libraryPickerRequestedEvent, show); return () => window.removeEventListener(libraryPickerRequestedEvent, show); - }, [draft, draftRev, sessionId]); - return <>; + }, []); + React.useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); setError(''); setRows([]); + void management.list(parentId).then(items => { if (!cancelled) setRows(items); }) + .catch(cause => { if (!cancelled) setError(cause instanceof Error ? cause.message : '无法加载资料库,请重试。'); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [open, parentId, management]); + React.useEffect(() => { + if (!open) return; + const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); }; + const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setOpen(false); trigger.current?.focus(); } }; + document.addEventListener('pointerdown', outside); document.addEventListener('keydown', escape); + return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape); }; + }, [open]); + const pick = (row: LibraryTreeEntry) => { + if (row.kind === 'folder') { setPath(current => [...current, row]); return; } + if (addReference(String(sessionId), row)) setOpen(false); + else setError('无法添加引用,请等待输入框就绪后重试。'); + }; + return <>
+ + {open &&
+ {parentId && } + {loading &&
正在加载资料…
} + {error &&
{error}
} + {!loading && !error && !rows.length &&
暂无资料,可在资料库中导入文件。
} + {rows.map(row =>
+ + +
)} + +
} +
; }