Merge origin/master and align the Team upgrade guide with alpha.4
This commit is contained in:
commit
dccc3ecf6f
412 files changed
+1936
-499
No files matched your search
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: ca2848e3bbc3a009
|
||||
zh: 0551e32ff6d187d0
|
||||
/agent-note-agent-teams-teammates-are-subagents/decision:
|
||||
en: b639631e54322fd9
|
||||
zh: b80c1dad17f9afd1
|
||||
en: 5742777c069a9bb6
|
||||
zh: ffddb9c47183da7e
|
||||
/agent-note-agent-teams-teammates-are-subagents/alternatives-considered:
|
||||
en: ef0cf648ff54784f
|
||||
zh: 2a0b3745a54e7072
|
||||
|
||||
@@ -18,7 +18,7 @@ Every root Agent leads the Team identified by its Session id; every live subagen
|
||||
|
||||
Task ownership uses `ownerId`, and reassignment accepts a Session id. Team persists only task changes. Messages use the target Agent inbox; historical `team/message/*` records remain readable but are never delivered or acknowledged.
|
||||
|
||||
This supersedes the roster, provisioning, recovery, and messaging decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and the [Team direct inbox decision](../../archived/simplification/2026-09-26-team-direct-inbox.md). The task-board and shared-checkout decisions remain active. [Adjacent Agent messaging](2026-08-27-adjacent-agent-steer-messaging.md) includes sibling routing, while [required runtime-context identity](2026-10-13-team-identity-runtime-context.md) owns the role statement. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.3/agent-team-subagents/guide.md) lists removed tools, methods, and configuration.
|
||||
This supersedes the roster, provisioning, recovery, and messaging decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and the [Team direct inbox decision](../../archived/simplification/2026-09-26-team-direct-inbox.md). The task-board and shared-checkout decisions remain active. [Adjacent Agent messaging](2026-08-27-adjacent-agent-steer-messaging.md) includes sibling routing, while [required runtime-context identity](2026-10-13-team-identity-runtime-context.md) owns the role statement. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.4/agent-team-subagents/guide.md) lists removed tools, methods, and configuration.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
任务所有权使用 `ownerId`,重新分配接受 Session id。Team 只持久化任务变更。消息使用目标 Agent 收件箱;历史 `team/message/*` 记录仍可读取,但不会被投递或确认。
|
||||
|
||||
本决策取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的成员列表、创建、恢复和消息决策,以及 [Team 直接收件箱决策](../../archived/simplification/2026-09-26-team-direct-inbox.md)。任务板和共享工作树决策仍有效。[相邻 Agent 消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)纳入同级路由,[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责角色声明。[升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.3/agent-team-subagents/guide.zh.md)列出移除的工具、方法和配置。
|
||||
本决策取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的成员列表、创建、恢复和消息决策,以及 [Team 直接收件箱决策](../../archived/simplification/2026-09-26-team-direct-inbox.md)。任务板和共享工作树决策仍有效。[相邻 Agent 消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)纳入同级路由,[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责角色声明。[升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.4/agent-team-subagents/guide.zh.md)列出移除的工具、方法和配置。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop-host",
|
||||
"description": "Private Node-mode host process for the Electron desktop application",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop",
|
||||
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-plugin-market-generator",
|
||||
"description": "Reference generator of plugin market index files from a list of npm packages and Git repositories",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-frontend",
|
||||
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -78,6 +78,8 @@ interface GalleryCall {
|
||||
/** The five successful writes; docs/press.md and src/tokens.css join via other tools. */
|
||||
const WRITES = ['site/report.html', 'a/style.css', 'b/style.css', 'site/index.html', 'site/app.js']
|
||||
|
||||
const LONG_COMMAND_DESCRIPTION = 'Check every generated stylesheet and component theme against the shared design tokens, inspect the application pages for inconsistent colors, and report all remaining lint failures before publishing the production build'
|
||||
|
||||
/** The gallery turn's tool calls, in surface order. */
|
||||
const CALLS: GalleryCall[] = [
|
||||
...WRITES.map(path => ({
|
||||
@@ -166,6 +168,16 @@ const CALLS: GalleryCall[] = [
|
||||
args: { command: 'pnpm run lint', description: 'Run the lint gate', workdir: 'site' },
|
||||
result: 'style.css: unexpected hex literal\n[exit code: 1]',
|
||||
},
|
||||
{
|
||||
name: 'bash',
|
||||
args: { command: 'pnpm run lint:styles', description: LONG_COMMAND_DESCRIPTION, workdir: 'site' },
|
||||
result: 'theme.css: unexpected hex literal\n[exit code: 1]',
|
||||
},
|
||||
{
|
||||
name: 'bash',
|
||||
args: { command: 'pnpm run build', description: 'Build the site', workdir: 'site' },
|
||||
result: '(no output)\n[timed out after 100ms]\n[exit code: null]',
|
||||
},
|
||||
{
|
||||
name: 'web_search',
|
||||
args: { queries: ['clickable link styles', 'produced files ui'] },
|
||||
@@ -378,6 +390,55 @@ describe('web e2e: clickable links gallery', () => {
|
||||
expect(await fetchSummaryLink.getAttribute('target')).toBe('_blank')
|
||||
|
||||
await expandTurnProcesses(page)
|
||||
const failedCommands = [
|
||||
{ description: 'Run the lint gate', long: false },
|
||||
{ description: LONG_COMMAND_DESCRIPTION, long: true },
|
||||
].map(({ description, long }) => ({
|
||||
row: page.locator('[data-sample="bash"]').filter({ hasText: description }),
|
||||
long,
|
||||
}))
|
||||
try {
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark')
|
||||
for (const { row, long } of failedCommands) {
|
||||
const exitBadge = row.getByText('exit code 1', { exact: true })
|
||||
await exitBadge.waitFor()
|
||||
for (const width of ['760px', '260px']) {
|
||||
await row.evaluate((element, value) => { element.style.width = value }, width)
|
||||
const geometry = await exitBadge.evaluate((badge) => {
|
||||
const description = badge.previousElementSibling!
|
||||
const row = badge.closest('[data-sample]')!
|
||||
const textRect = description.getBoundingClientRect()
|
||||
const badgeRect = badge.getBoundingClientRect()
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
return {
|
||||
neutral: getComputedStyle(description).color === getComputedStyle(row).color,
|
||||
fits: badgeRect.right <= rowRect.right + 1 && badgeRect.bottom <= rowRect.bottom + 1,
|
||||
inline: Math.abs(badgeRect.top - textRect.top) < 5,
|
||||
truncated: description.scrollWidth > description.clientWidth,
|
||||
ellipsis: getComputedStyle(description).textOverflow === 'ellipsis',
|
||||
singleLine: Math.abs(rowRect.height - textRect.height) < 1,
|
||||
height: rowRect.height,
|
||||
gap: badgeRect.left - textRect.right,
|
||||
}
|
||||
})
|
||||
const context = JSON.stringify({ width, colorScheme, long, ...geometry })
|
||||
expect(geometry.neutral, context).toBe(true)
|
||||
expect(geometry.fits, context).toBe(true)
|
||||
expect(geometry.inline, context).toBe(true)
|
||||
expect(geometry.singleLine, context).toBe(true)
|
||||
expect(geometry.height, context).toBeCloseTo(24, 0)
|
||||
if (long || width === '760px') expect(geometry.truncated, context).toBe(long)
|
||||
expect(geometry.ellipsis, context).toBe(true)
|
||||
expect(geometry.gap, context).toBeCloseTo(8, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const { row } of failedCommands) await row.evaluate((element) => { element.style.width = '' })
|
||||
await page.emulateMedia({ colorScheme: null })
|
||||
}
|
||||
// Clicking the collapsed summary link opens the page without expanding the row.
|
||||
const fetchPopupPromise = page.waitForEvent('popup')
|
||||
await fetchSummaryLink.click()
|
||||
@@ -397,12 +458,20 @@ describe('web e2e: clickable links gallery', () => {
|
||||
/^Edit src\/tokens\.css/,
|
||||
/^Grep linkColor/,
|
||||
/Run the lint gate|pnpm run lint/,
|
||||
/Build the site/,
|
||||
]) {
|
||||
const toggle = page.getByRole('button', { name: row }).first()
|
||||
await toggle.waitFor({ timeout: 10_000 })
|
||||
const box = await toggle.boundingBox()
|
||||
await toggle.click(box === null ? {} : { position: { x: box.width - 8, y: box.height / 2 } })
|
||||
}
|
||||
const noExitCommand = page.locator('[data-sample="bash"]').filter({ hasText: 'Build the site' })
|
||||
expect(await noExitCommand.getAttribute('data-state')).toBe('error')
|
||||
expect(await noExitCommand.getByText('no exit code', { exact: true }).count()).toBe(1)
|
||||
const noExitTerminal = page.locator('[data-terminal]').filter({ hasText: 'pnpm run build' })
|
||||
expect(await noExitTerminal.getByText('no exit code', { exact: true }).count()).toBe(1)
|
||||
expect(await noExitTerminal.getByText('[timed out after 100ms]', { exact: true }).count()).toBe(1)
|
||||
expect(await noExitTerminal.getByText('[exit code: null]', { exact: true }).count()).toBe(0)
|
||||
// Both generic rows (the unclassified str_replace_editor and the unknown
|
||||
// design_tokens_sync) expand to their IN/OUT surfaces.
|
||||
const genericRows = page.getByRole('button', { name: /^Tool call/ })
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- button "Copy"
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [expanded]
|
||||
- button "Wrote files, called tools, searched code, etc." [expanded]
|
||||
- button "Wrote files, ran commands, called tools, etc." [expanded]
|
||||
- button "Write site/report.html 1KB +1 -0":
|
||||
- text: Write
|
||||
- button "site/report.html"
|
||||
@@ -65,11 +65,17 @@
|
||||
- button "Inspect"
|
||||
- button "Glob **/*.css"
|
||||
- button "Bash List the built site"
|
||||
- button "Failed Bash Run the lint gate" [expanded]
|
||||
- button "Failed Bash Run the lint gate exit code 1" [expanded]
|
||||
- text: Failed site pnpm run lint exit code 1
|
||||
- button "Copy"
|
||||
- text: "style.css: unexpected hex literal"
|
||||
- button "Inspect"
|
||||
- button "Failed Bash Check every generated stylesheet and component theme against the shared design tokens, inspect the application pages for inconsistent colors, and report all remaining lint failures before publishing the production build exit code 1"
|
||||
- button "Failed Bash Build the site no exit code" [expanded]
|
||||
- text: Failed site pnpm run build no exit code
|
||||
- button "Copy"
|
||||
- text: (no output) [timed out after {{duration}}]
|
||||
- button "Inspect"
|
||||
- button "Search clickable link styles, produced files ui" [expanded]
|
||||
- paragraph:
|
||||
- text: Unify links per
|
||||
|
||||
@@ -59,6 +59,40 @@
|
||||
- text: 文本
|
||||
- checkbox "图片" [checked]
|
||||
- text: 图片
|
||||
- group "推理等级 1":
|
||||
- text: 推理等级
|
||||
- button "推理等级说明": "?"
|
||||
- switch "推理等级 1" [checked]
|
||||
- combobox "档位 1":
|
||||
- option "off" [selected]
|
||||
- option "minimal"
|
||||
- option "medium"
|
||||
- option "xhigh"
|
||||
- option "max"
|
||||
- textbox "off API 参数值" [disabled]:
|
||||
- /placeholder: 关闭思考
|
||||
- button "删除档位 off"
|
||||
- combobox "档位 2":
|
||||
- option "minimal"
|
||||
- option "low" [selected]
|
||||
- option "medium"
|
||||
- option "xhigh"
|
||||
- option "max"
|
||||
- textbox "low API 参数值":
|
||||
- /placeholder: low
|
||||
- text: low
|
||||
- button "删除档位 low"
|
||||
- combobox "档位 3":
|
||||
- option "minimal"
|
||||
- option "medium"
|
||||
- option "high" [selected]
|
||||
- option "xhigh"
|
||||
- option "max"
|
||||
- textbox "high API 参数值":
|
||||
- /placeholder: high
|
||||
- text: high
|
||||
- button "删除档位 high"
|
||||
- button "添加档位"
|
||||
- button "添加模型"
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
|
||||
@@ -59,6 +59,40 @@
|
||||
- text: 文本
|
||||
- checkbox "图片" [checked] [disabled]
|
||||
- text: 图片
|
||||
- group "推理等级 1":
|
||||
- text: 推理等级
|
||||
- button "推理等级说明": "?"
|
||||
- switch "推理等级 1" [checked]
|
||||
- combobox "档位 1":
|
||||
- option "off" [selected]
|
||||
- option "minimal"
|
||||
- option "medium"
|
||||
- option "xhigh"
|
||||
- option "max"
|
||||
- textbox "off API 参数值" [disabled]:
|
||||
- /placeholder: 关闭思考
|
||||
- button "删除档位 off"
|
||||
- combobox "档位 2":
|
||||
- option "minimal"
|
||||
- option "low" [selected]
|
||||
- option "medium"
|
||||
- option "xhigh"
|
||||
- option "max"
|
||||
- textbox "low API 参数值":
|
||||
- /placeholder: low
|
||||
- text: low
|
||||
- button "删除档位 low"
|
||||
- combobox "档位 3":
|
||||
- option "minimal"
|
||||
- option "medium"
|
||||
- option "high" [selected]
|
||||
- option "xhigh"
|
||||
- option "max"
|
||||
- textbox "high API 参数值":
|
||||
- /placeholder: high
|
||||
- text: deep
|
||||
- button "删除档位 high"
|
||||
- button "添加档位"
|
||||
- button "添加模型"
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
// while the settings document records only that reference. Each saved row
|
||||
// appears after route topology invalidation without presenting liveness as
|
||||
// provider status. The customized-settings fold writes its curated fields —
|
||||
// the endpoint, and a declared route's own name and protocol — as merge
|
||||
// patches against the stored profile. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud because the adapter registry is empty. The provider under test is
|
||||
// the endpoint, a declared route's own name and protocol, and the reasoning
|
||||
// levels of its models — as merge patches against the stored profile. Zero
|
||||
// model calls: configuration is pure settings/credentials/llm-domain traffic,
|
||||
// so there is no fixture and a stray stream would fail loud because the
|
||||
// adapter registry is empty. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
// never shadow the derived reference. The deletion dialog distinguishes a
|
||||
// reference-free profile from a page-managed key before the credential and
|
||||
@@ -300,23 +301,29 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await custom.getByLabel('Provider ID').fill('acme-gateway')
|
||||
await custom.getByLabel('显示名称').fill('Acme Gateway')
|
||||
await custom.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
|
||||
// No reasoning effort on a provider card at all: effort is a per-model
|
||||
// capability, the models under one provider disagree about it, and a
|
||||
// switch in the composer already records provider+model+effort together.
|
||||
// No provider-scoped reasoning control: effort is a per-model capability
|
||||
// and the models under one provider disagree about it, so each model row
|
||||
// declares its own levels instead.
|
||||
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
|
||||
await custom.getByRole('button', { name: '添加模型', exact: true }).click()
|
||||
await custom.getByLabel('模型 ID 1').fill('acme-large')
|
||||
await custom.getByRole('button', { name: '模型选项 1' }).click()
|
||||
expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
|
||||
await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
|
||||
// The switch seeds off, low, medium, and high rows; this route serves no medium.
|
||||
const reasoning = custom.getByRole('group', { name: '推理等级 1' })
|
||||
await reasoning.getByRole('switch', { name: '推理等级 1' }).click()
|
||||
await reasoning.getByRole('button', { name: '删除档位 medium', exact: true }).click()
|
||||
await custom.getByRole('button', { name: '创建提供商', exact: true }).click()
|
||||
|
||||
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
|
||||
expect(document).toContain('acme-gateway:')
|
||||
expect(document).toContain('reasoningEfforts:')
|
||||
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
|
||||
inputModalities: ['text', 'image'],
|
||||
reasoning: { efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] },
|
||||
})
|
||||
|
||||
// The tag follows the adapter's installed catalog: this route is in no
|
||||
@@ -345,6 +352,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(await name.inputValue()).toBe('Acme Gateway')
|
||||
await dialog.getByRole('button', { name: '模型选项 1' }).click()
|
||||
expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
|
||||
// The stored levels come back as rows beside the values they send.
|
||||
const reasoning = dialog.getByRole('group', { name: '推理等级 1' })
|
||||
expect(await reasoning.getByRole('combobox').evaluateAll(selects => selects.map(select => (select as HTMLSelectElement).value)))
|
||||
.toEqual(['off', 'low', 'high'])
|
||||
expect(await reasoning.getByLabel('high API 参数值').inputValue()).toBe('high')
|
||||
await assertModelInputLayout(page, dialog)
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
|
||||
@@ -352,6 +364,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await protocol.selectOption('anthropic-messages')
|
||||
await name.fill('Acme 网关')
|
||||
await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).uncheck()
|
||||
// A gateway with its own spelling for the top level.
|
||||
await reasoning.getByLabel('high API 参数值').fill('deep')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
|
||||
// The adapter re-resolved the route under the new protocol and re-registered
|
||||
@@ -365,8 +379,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
|
||||
expect(document).toContain('api: anthropic-messages')
|
||||
expect(document).toContain('displayName: Acme 网关')
|
||||
expect(document).toContain('high: deep')
|
||||
// The level keeps its id and name; only its wire spelling changed.
|
||||
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
|
||||
inputModalities: ['text'],
|
||||
reasoning: { efforts: [{ id: 'off' }, { id: 'low' }, { id: 'high', name: 'High' }] },
|
||||
})
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
@@ -287,8 +287,10 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'sidebar-terminal'))
|
||||
const unaryInput: string[] = []
|
||||
const listRequests: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (/\/api\/terminal\/(write|resize|follow)$/u.test(new URL(request.url()).pathname)) unaryInput.push(request.url())
|
||||
if (new URL(request.url()).pathname === '/api/terminal/list') listRequests.push(request.url())
|
||||
})
|
||||
await openTerminal(page)
|
||||
const terminal = page.locator('[data-sidebar-terminal]')
|
||||
@@ -335,6 +337,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`)
|
||||
await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true })
|
||||
const tabIds = await page.locator('[data-dockkit-tab]').evaluateAll(tabs => tabs.map(tab => tab.getAttribute('data-dockkit-tab')))
|
||||
expect(listRequests).toHaveLength(0)
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash'])
|
||||
@@ -344,6 +347,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
expect(await page.locator('[data-dockkit-tab]').evaluateAll(tabs => tabs.map(tab => tab.getAttribute('data-dockkit-tab')))).toEqual(tabIds)
|
||||
await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
|
||||
await page.getByRole('button', { name: 'Exit fullscreen', exact: true }).waitFor()
|
||||
expect(listRequests).toHaveLength(1)
|
||||
await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.locator('[data-sidebar-right-expand]').waitFor()
|
||||
@@ -354,6 +358,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' })
|
||||
await secondTab.click()
|
||||
await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`)
|
||||
expect(listRequests).toHaveLength(2)
|
||||
await secondTab.hover()
|
||||
await secondTab.locator('[data-dockkit-tab-close]').click()
|
||||
await expect.poll(async () => await secondTab.count()).toBe(0)
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-alpha.3/agent-team-subagents/guide.md
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/agent-team-subagents/guide.md
|
||||
/:
|
||||
en: 1f073189776760d8
|
||||
zh: cfbb0403aea96f84
|
||||
/agent-teams-uses-subagents-and-session-ids:
|
||||
en: 32ee389e58de25fe
|
||||
zh: 420fd4344f8f1214
|
||||
en: a6aba40e910d4ecb
|
||||
zh: 11881b6e9d8228d9
|
||||
/agent-teams-uses-subagents-and-session-ids/change:
|
||||
en: 208add38ac8dc327
|
||||
zh: c34fce9b2a978e5f
|
||||
File renamed without changes.
File renamed without changes.
@@ -21,8 +21,8 @@
|
||||
en: 6a94264c99535caf
|
||||
zh: f97bb0dc6a40c0d3
|
||||
/configure-models/advanced-configuration:
|
||||
en: aaefe21c2fb77396
|
||||
zh: c02d1e2a6b2a1290
|
||||
en: b882ec69a6899d8a
|
||||
zh: 66a1713aca0e6f1c
|
||||
/configure-models/advanced-configuration/image-input:
|
||||
en: 75350cb615da03a4
|
||||
zh: 6f8b3510085e4ac4
|
||||
@@ -30,11 +30,11 @@
|
||||
en: fc09d02a89b211a7
|
||||
zh: e0f23a8912a24178
|
||||
/configure-models/advanced-configuration/reasoning-effort:
|
||||
en: f7581a0760fdfd2c
|
||||
zh: dc6eaa16f74a0e9f
|
||||
en: f2ffb493997dcc72
|
||||
zh: dee9238f072e53ad
|
||||
/configure-models/advanced-configuration/request-compatibility:
|
||||
en: 6f52195d1502783e
|
||||
zh: 34dbbeacb541afd4
|
||||
/configure-models/troubleshooting:
|
||||
en: 0477cd6fa2772d6b
|
||||
zh: 780619fe998bdef7
|
||||
en: af9f866bec7701f0
|
||||
zh: 0e4f5e1da975be41
|
||||
@@ -47,7 +47,7 @@ If a saved default names a provider that was deleted, the composer displays **Se
|
||||
The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default for every plugin; [`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) is the provider section this page configures. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `cordis.patch.yml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors.
|
||||
|
||||
::: tip Additional settings
|
||||
The Models page exposes the API key, display name, base URL, API protocol, and each model's id, display name, context window, max output tokens, and input types. Configure reasoning effort levels, request-compatibility switches, headers, timeouts, and retry policy in `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need.
|
||||
The Models page exposes the API key, display name, base URL, API protocol, and each model's id, display name, context window, max output tokens, and input types, plus the reasoning levels of a model on a custom model API provider. Configure the reasoning levels of a built-in provider's models, request-compatibility switches, headers, timeouts, and retry policy in `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need.
|
||||
|
||||
For the standard Web UI launch with `dsh web`, `<profile>` is `web`, so the path is `$DSH_HOME/profiles/web/cordis.patch.yml`. If you launch a custom profile, use the name selected at startup instead.
|
||||
:::
|
||||
@@ -129,7 +129,11 @@ A model that needs a newer DeepSeek Harness fails with `CLIENT_UPGRADE_REQUIRED`
|
||||
|
||||
### Reasoning effort
|
||||
|
||||
The model picker offers an **Effort** menu for a model that declares reasoning levels. A built-in provider's models inherit their levels from the installed catalog. A model you enter by hand declares none, so the Effort entry does not appear in the menu and the endpoint's own default decides whether the model thinks. Declare the levels with `reasoningEfforts` in `$DSH_HOME/profiles/<profile>/cordis.patch.yml`:
|
||||
The model picker offers an **Effort** menu for a model that declares reasoning levels. A built-in provider's models inherit their levels from the installed catalog, and DeepSeek's models keep the adapter's levels. A model you enter by hand declares none until you add them, so until then the Effort entry does not appear in the menu and the endpoint's own default decides whether the model thinks.
|
||||
|
||||
On a custom model API provider, declare the levels in **Settings → Models**: edit the provider, open **Customized settings**, expand the model's **Model options**, and turn on **Reasoning levels**. The rows start with off, low, medium, and high. Each row pairs a level with the **API value** sent for it, which defaults to the level name; change it only where the model provider's API documentation spells the level differently. Add or delete rows to match what the endpoint supports, keep at least one level besides off, and save. Turning the switch off removes the declaration.
|
||||
|
||||
The page writes `reasoningEfforts` in `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, where you can also declare the levels directly:
|
||||
|
||||
```yaml
|
||||
- id: llm-pi-ai
|
||||
@@ -216,7 +220,7 @@ Every switch, its accepted values, and the protocols that take it are listed und
|
||||
- **Fetching available models reports neither a `data` array nor a `models` object** — The endpoint's listing is in a format discovery does not read. Enter the models by hand.
|
||||
- **The gateway refuses every request although the key and URL are right** — Its request shape differs from OpenAI's. Start with `compat.supportsDeveloperRole: false` and `compat.maxTokensField: max_tokens` on the route.
|
||||
- **Only reasoning models fail** — pi-ai sends their system prompt as the `developer` role, which the gateway rejects. Set `compat.supportsDeveloperRole: false`.
|
||||
- **The Effort menu does not appear for a model you entered by hand** — It declares no levels. Add `reasoningEfforts` to the model in `cordis.patch.yml`.
|
||||
- **The Effort menu does not appear for a model you entered by hand** — It declares no levels. On a custom model API provider, turn on **Reasoning levels** in the model's **Model options**; elsewhere, add `reasoningEfforts` to the model in `cordis.patch.yml`.
|
||||
- **`off` does not stop a DeepSeek model from thinking** — An empty `off` sends no reasoning field at all, and an endpoint that thinks by default keeps thinking. Set `compat.thinkingFormat: deepseek` on the model or the route.
|
||||
- **A compat switch is refused as having no value** — A key written with nothing after the colon. Give it a value, or remove the key to keep the installed catalog's.
|
||||
- **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; on DeepSeek's own route, select an image-capable entry from the configured catalog (`deepseek-flash` by default) and confirm that your gateway serves that model with image input.
|
||||
|
||||
@@ -47,7 +47,7 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
|
||||
自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供商段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `cordis.patch.yml` 配置、目录解析、推理控制、凭据与适配器错误。
|
||||
|
||||
::: tip 其他设置
|
||||
模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
|
||||
模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型;自定义模型 API 提供商的模型还可以在这里设置推理等级。内置提供商模型的推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
|
||||
|
||||
按常规方式通过 `dsh web` 启动 Web UI 时,`<profile>` 就是 `web`,完整路径为 `$DSH_HOME/profiles/web/cordis.patch.yml`。如果使用自定义 profile,请替换为启动时指定的名称。
|
||||
:::
|
||||
@@ -129,7 +129,11 @@ DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。
|
||||
|
||||
### 推理等级
|
||||
|
||||
对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供商的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 中用 `reasoningEfforts` 声明等级:
|
||||
对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供商的模型从已安装目录继承其等级,DeepSeek 的模型沿用适配器提供的等级。手动录入的模型在添加等级之前不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。
|
||||
|
||||
自定义模型 API 提供商可以在**设置 → 模型**中声明等级:编辑该提供商,打开**自定义设置**,展开该模型的**模型选项**,然后打开**推理等级**。默认带有 off、low、medium、high 四行。每行把一个档位与发送给它的 **API 参数值**配成一对,参数值默认与档位名相同;只有模型提供商的 API 文档写法不同时才需要修改。按端点支持的档位增删行,至少保留一个 off 以外的档位,然后保存。关闭开关会删除该声明。
|
||||
|
||||
模型页会把 `reasoningEfforts` 写入 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`,也可以直接在其中声明等级:
|
||||
|
||||
```yaml
|
||||
- id: llm-pi-ai
|
||||
@@ -216,7 +220,7 @@ DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。
|
||||
- **获取可用模型提示既没有 `data` 数组也没有 `models` 对象**:端点返回的列表格式不在探测的读取范围内。请手动输入模型。
|
||||
- **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。
|
||||
- **只有推理模型失败**:pi-ai 把它们的系统提示词以 `developer` 角色发出,而网关拒绝该角色。设 `compat.supportsDeveloperRole: false`。
|
||||
- **手动录入的模型没有推理等级菜单**:该模型没有声明任何等级。在 `cordis.patch.yml` 中给该模型加上 `reasoningEfforts`。
|
||||
- **手动录入的模型没有推理等级菜单**:该模型没有声明任何等级。如果是自定义模型 API 提供商,在该模型的**模型选项**中打开**推理等级**;其他提供商在 `cordis.patch.yml` 中给该模型加上 `reasoningEfforts`。
|
||||
- **`off` 无法让 DeepSeek 模型停止思考**:留空的 `off` 不发送任何推理字段,默认思考的端点就继续思考。请在模型或路由上设置 `compat.thinkingFormat: deepseek`。
|
||||
- **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。
|
||||
- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供商的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-root",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-account-controller",
|
||||
"description": "Expose safe account operations over authenticated Remote",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "Typert Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-job-controller",
|
||||
"description": "Job Remote observation stream and the reference-counted client job-output service",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remote-access",
|
||||
"description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly for application-selected Host capabilities",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-session-controller",
|
||||
"description": "Session Remote commands, cold reads, and live control transport",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-settings-controller",
|
||||
"description": "Remote owner for the configuration surfaces over the settings-domain seams",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 056c8b23819be51f
|
||||
zh: d4525149ad4c32ce
|
||||
/deepseek-ai-dsh-api-terminal-controller/understand-the-implementation:
|
||||
en: b34684c82aa5fe5f
|
||||
zh: 07638715c8708546
|
||||
en: a3216b572cb70a6b
|
||||
zh: 3adb2a394488b026
|
||||
/deepseek-ai-dsh-api-terminal-controller/further-exploration:
|
||||
en: b1b5d2a2bfb8cc6a
|
||||
zh: aa0ea568aa63fda5
|
||||
|
||||
@@ -59,6 +59,8 @@ The opening snapshot advertises the carrier's complete-frame `inputWindow`; in-p
|
||||
|
||||
The Client saves each Session/content-to-terminal association before allocation under its own `dsh.terminal.binding.v1.*` localStorage key. The content identity is globally unique; layout-local tab ids only identify live view occurrences. Independent record writes and deletes preserve other windows' bindings. Restored views reuse that identity; the sidebar terminal provider restores its views before querying unrepresented Host terminals. A new view may create a process, while a recovered view reports a missing target without creating a replacement. Explicit close removes the association after saving its cleanup request. The Host supplies current process metadata and screen contents; neither is saved in the browser. The Client model acknowledges screen writes after the browser emulator processes them, pipelines input within its unacknowledged UTF-8 byte budget and ignores stale attachment responses. Reconnection creates a fresh command sequence and never replays unacknowledged input. Provider input failures end the attachment; its process and recovery screen remain available for explicit reconnection. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes.
|
||||
|
||||
Client discovery belongs to each Session in the current window. Concurrent and later restored views share one list, including an empty result; new identities allocate directly from `create`. Explicit retry replaces a settled result or failure, and reconnection refreshes each previously discovered Session once. Remounting a failed view preserves its error without retrying. Confirmed mutations and attachment metadata take precedence over an older pending list response, and responses from replaced connections cannot publish. A view releasing its DOM or lifetime does not cancel another view’s lookup. Plugin disposal discards cached metadata and joins outstanding discovery. Other windows’ unrepresented terminals become discoverable through explicit refresh or reconnection; current attachments carry their own live metadata. Environment lookup remains separate because its working directory can change.
|
||||
|
||||
The separate `retain(sessionId, id, signal)` Remote stream acknowledges a window hold without Agent activation, screen output, input transfer or process creation. The terminal provider supplies the sidebar's complete open-tab inventory, and the Client intersects it with its own saved identities. Duplicate occurrences share one hold per window; stale associations alone retain nothing. Restored output attachments wait for an acknowledged current hold. Transport cancellation releases exactly that physical generation; plugin disposal joins all hold streams. Failed cleanup retains ownership and retries without reopening admission or restarting the idle grace.
|
||||
|
||||
New views start automatically, using an explicit guide selection or the remembered available shell. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. Default launches verify the saved path through Host discovery and fall back to the current default when it is absent. The guide records a selection before opening its tab; each new tab retains its own chosen path and allocation identity. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells.
|
||||
|
||||
@@ -59,6 +59,8 @@ Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终
|
||||
|
||||
Client 在分配前将每条 Session/内容与终端身份的关联保存到独立的 localStorage key `dsh.terminal.binding.v1.*`。内容身份全局唯一;布局内的 tab id 只标识活动视图 occurrence。逐条记录的写入和删除会保留其他窗口的关联。恢复视图复用该身份;侧栏 terminal provider 先恢复自己的视图,再查询尚无视图的 Host 终端。新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。显式关闭先保存清理请求,再删除关联。当前进程元数据和屏幕内容由 Host 提供,不保存在浏览器中。Client 模型在浏览器完成屏幕解析后确认帧,在未确认 UTF-8 字节预算内连续发送输入,并忽略旧连接迟到的响应。重连建立新的命令序列,绝不重放未确认输入。提供者输入失败会结束附件;进程和恢复屏幕仍可供显式重连使用。Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。
|
||||
|
||||
客户端发现状态由当前窗口中的各个 Session 持有。并发及稍后恢复的视图共享一次列表查询,包括空结果;新标识直接通过 `create` 分配终端。显式重试会替换已完成的结果或失败,重连则为每个已经查询过的 Session 刷新一次列表。失败视图重新挂载时保留错误,不会重试。已确认的操作和 attachment 元数据优先于较早发起的列表响应,被替换连接的响应不能发布状态。单个视图释放 DOM 或结束生命周期不会取消其他视图的查询。插件卸载会丢弃缓存元数据并等待未完成的发现请求结束。其他窗口中尚未出现在本窗口的终端可通过显式刷新或重连发现;当前 attachment 自行传递实时元数据。环境查询保持独立,因为工作目录可能变化。
|
||||
|
||||
独立的 `retain(sessionId, id, signal)` Remote 流确认窗口持有关系,不激活 Agent、发送屏幕输出、转移输入权或创建进程。终端 provider 提供侧栏的完整打开标签清单,Client 将其与自己保存的身份取交集。同一窗口中的重复 occurrence 共用一个持有流;孤立的旧关联不会保活任何终端。恢复输出连接前必须等待当前持有关系确认。传输取消只释放对应的物理流代次;插件卸载等待全部持有流结束。清理失败时保留所有权并重试,不重新接受连接,也不重新计算空闲宽限期。
|
||||
|
||||
新视图自动启动,使用开始页明确选中的 shell,或上次选择且仍可用的 shell。上次选择的路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。默认启动通过 Host 探测验证保存的路径,不可用时回到当前默认项。开始页在打开标签页前记录选择,每个新标签页保留自己的 shell 路径和分配身份。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-terminal-controller",
|
||||
"description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -73,6 +73,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:*",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs": "workspace:*",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:*",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { TerminalView, type TerminalRemote } from './model.ts'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
@@ -11,6 +12,7 @@ import { preferredShell, rememberShell } from './shell-preference.ts'
|
||||
import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts'
|
||||
import { TerminalWindowHold } from './retention.ts'
|
||||
import { TerminalBindings } from './bindings.ts'
|
||||
import { TerminalInventory } from './inventory.ts'
|
||||
|
||||
export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts'
|
||||
|
||||
@@ -43,6 +45,7 @@ export class ClientTerminals extends Service {
|
||||
private readonly closed = new Set<WebTerminalId>(this.requests.pending().map(request => request.id))
|
||||
private disposed = false
|
||||
private readonly views = new Map<SessionId, Map<string, TerminalView>>()
|
||||
private readonly inventories = new Map<SessionId, TerminalInventory>()
|
||||
private readonly bindings = new TerminalBindings()
|
||||
private readonly holds = new Map<SessionId, Map<WebTerminalId, TerminalWindowHold>>()
|
||||
private readonly releasing = new Set<Promise<void>>()
|
||||
@@ -54,14 +57,28 @@ export class ClientTerminals extends Service {
|
||||
*/
|
||||
constructor(ctx: Context, private readonly remote: TerminalRemote) {
|
||||
super(ctx, 'webTerminals')
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
let connected = connection.generation.getSnapshot() !== undefined
|
||||
ctx.on('connection/reset', () => {
|
||||
if (!connected) { connected = true; return }
|
||||
for (const [sessionId, inventory] of this.inventories) {
|
||||
void inventory.reset()?.catch((error: unknown) => {
|
||||
if (!this.disposed) {
|
||||
this.ctx.logger.warn('Terminal list refresh failed; retry terminal recovery', { sessionId, error })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
ctx.effect(() => async () => {
|
||||
this.disposed = true
|
||||
const listing = [...this.inventories.values()].map(inventory => inventory.dispose())
|
||||
this.inventories.clear()
|
||||
const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose()))
|
||||
this.views.clear()
|
||||
this.bindings.clear()
|
||||
const holds = [...this.holds.values()].flatMap(holds => [...holds.values()].map(hold => hold.dispose()))
|
||||
this.holds.clear()
|
||||
await Promise.all([...detaching, ...holds, ...this.releasing, ...this.closing.values()])
|
||||
await Promise.all([...listing, ...detaching, ...holds, ...this.releasing, ...this.closing.values()])
|
||||
}, 'terminal-controller.client.views')
|
||||
for (const request of this.requests.pending()) this.cleanup(request)
|
||||
}
|
||||
@@ -83,7 +100,7 @@ export class ClientTerminals extends Service {
|
||||
const saved = terminalId ?? this.bindings.get(sessionId, contentId)
|
||||
const id = saved ?? randomUUID() as WebTerminalId
|
||||
this.bindings.set(sessionId, contentId, id)
|
||||
view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, saved === undefined, shellPath,
|
||||
view = new TerminalView(sessionId, this.remote, this.ctx.remote, this.inventory(sessionId), id, saved === undefined, shellPath,
|
||||
signal => this.hold(sessionId, id).ready(signal))
|
||||
views.set(key, view)
|
||||
this.reconcileHolds()
|
||||
@@ -92,6 +109,15 @@ export class ClientTerminals extends Service {
|
||||
return view
|
||||
}
|
||||
|
||||
private inventory(sessionId: SessionId): TerminalInventory {
|
||||
let inventory = this.inventories.get(sessionId)
|
||||
if (inventory === undefined) {
|
||||
inventory = new TerminalInventory(() => this.remote.list(sessionId))
|
||||
this.inventories.set(sessionId, inventory)
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available launch choices on demand without allocating a PTY.
|
||||
* @param sessionId - target Session.
|
||||
@@ -183,16 +209,16 @@ export class ClientTerminals extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Host terminals without a live view or unfinished close.
|
||||
* Read shared Session discovery without a live view or unfinished close.
|
||||
* @param sessionId - Session being displayed.
|
||||
* @param refresh - explicitly replace settled discovery, including a previous failure.
|
||||
* @returns terminals available for opening as recovered tabs.
|
||||
*/
|
||||
async recover(sessionId: SessionId): Promise<WebTerminalInfo[]> {
|
||||
const result = await this.remote.list(sessionId)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
async recover(sessionId: SessionId, refresh: boolean = false): Promise<WebTerminalInfo[]> {
|
||||
const available = await this.inventory(sessionId).read(refresh)
|
||||
const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id))
|
||||
const closing = new Set(this.requests.pending().map(request => request.id))
|
||||
return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id))
|
||||
return [...available.values()].filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,6 +239,7 @@ export class ClientTerminals extends Service {
|
||||
const result = await this.remote.close(record.sessionId, record.id)
|
||||
if (!result.ok) throw result.error
|
||||
}
|
||||
this.inventories.get(record.sessionId)?.remove(record.id)
|
||||
this.requests.remove(record.id)
|
||||
})().catch((error: unknown) => {
|
||||
if (remoteErrorOf(error)?.code === 'session/not-found') {
|
||||
@@ -232,7 +259,7 @@ export class ClientTerminals extends Service {
|
||||
}
|
||||
|
||||
/** Required Client transport and terminal namespace. */
|
||||
export const inject = ['remote', 'remote.terminal']
|
||||
export const inject = ['remote', 'remote.terminal', 'connection']
|
||||
|
||||
/**
|
||||
* Install the Client terminal models.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Session-owned terminal discovery shared across view occurrences. */
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { WebTerminalId, WebTerminalInfo } from '../types.ts'
|
||||
|
||||
type Inventory = ReadonlyMap<WebTerminalId, WebTerminalInfo>
|
||||
interface Loading {
|
||||
readonly kind: 'loading'
|
||||
readonly promise: Promise<Inventory>
|
||||
readonly updates: Map<WebTerminalId, WebTerminalInfo | undefined>
|
||||
}
|
||||
type LoadState = { readonly kind: 'unloaded' | 'ready' | 'disposed' }
|
||||
| { readonly kind: 'failed'; readonly error: Error }
|
||||
| Loading
|
||||
|
||||
/** One Session's cached list, retained failures and in-flight mutation reconciliation. */
|
||||
export class TerminalInventory {
|
||||
private state: LoadState = { kind: 'unloaded' }
|
||||
private items = new Map<WebTerminalId, WebTerminalInfo>()
|
||||
private readonly pending = new Set<Promise<Inventory>>()
|
||||
|
||||
/** @param load - fetch the Session's retained Host terminals. */
|
||||
constructor(private readonly load: () => Promise<RemoteResult<WebTerminalInfo[]>>) {}
|
||||
|
||||
/**
|
||||
* Read the cached list or join its single in-flight request.
|
||||
* @param refresh - explicitly replace a settled result or failure; overlapping refreshes share one request.
|
||||
* @returns current terminal metadata; failures remain cached until refresh or reset.
|
||||
*/
|
||||
read(refresh: boolean = false): Promise<Inventory> {
|
||||
if (this.state.kind === 'disposed') return Promise.reject(new Error('Terminal inventory disposed'))
|
||||
if (this.state.kind === 'loading') return this.state.promise
|
||||
if (!refresh && this.state.kind === 'ready') return Promise.resolve(this.items)
|
||||
if (!refresh && this.state.kind === 'failed') return Promise.reject(this.state.error)
|
||||
const completed = Promise.withResolvers<Inventory>()
|
||||
const request: Loading = { kind: 'loading', promise: completed.promise, updates: new Map() }
|
||||
this.state = request
|
||||
this.pending.add(completed.promise)
|
||||
void this.loadRequest(request).then(completed.resolve, completed.reject)
|
||||
void completed.promise.then(() => { this.pending.delete(completed.promise) }, () => { this.pending.delete(completed.promise) })
|
||||
return completed.promise
|
||||
}
|
||||
|
||||
private async loadRequest(request: Loading): Promise<Inventory> {
|
||||
try {
|
||||
const result = await this.load()
|
||||
if (this.state !== request) return await this.read()
|
||||
if (!result.ok) throw result.error
|
||||
const items = new Map(result.value.map(info => [info.id, info]))
|
||||
for (const [id, info] of request.updates) {
|
||||
if (info === undefined) items.delete(id)
|
||||
else items.set(id, info)
|
||||
}
|
||||
this.items = items
|
||||
this.state = { kind: 'ready' }
|
||||
return items
|
||||
} catch (error) {
|
||||
if (this.state !== request) return this.read()
|
||||
const failure = error instanceof Error ? error : new Error('Terminal discovery failed', { cause: error })
|
||||
this.state = { kind: 'failed', error: failure }
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve acknowledged mutations and attachment metadata across a pending list response.
|
||||
* @param info - latest confirmed terminal metadata.
|
||||
*/
|
||||
update(info: WebTerminalInfo): void {
|
||||
if (this.state.kind === 'disposed') return
|
||||
this.items.set(info.id, info)
|
||||
if (this.state.kind === 'loading') this.state.updates.set(info.id, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a terminal after confirmed closure or unavailability.
|
||||
* @param id - terminal identity.
|
||||
*/
|
||||
remove(id: WebTerminalId): void {
|
||||
if (this.state.kind === 'disposed') return
|
||||
this.items.delete(id)
|
||||
if (this.state.kind === 'loading') this.state.updates.set(id, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace discovery after a connection change; never discover a Session that has not needed a list.
|
||||
* @returns the replacement lookup, or undefined for an unused or disposed inventory.
|
||||
*/
|
||||
reset(): Promise<Inventory> | undefined {
|
||||
if (this.state.kind === 'unloaded' || this.state.kind === 'disposed') return undefined
|
||||
this.state = { kind: 'unloaded' }
|
||||
return this.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard metadata and prevent late responses from publishing.
|
||||
* @returns after all owned list requests settle.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.state = { kind: 'disposed' }
|
||||
this.items.clear()
|
||||
await Promise.allSettled(this.pending)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/** React-free browser terminal state and reconnecting Remote-stream ownership. */
|
||||
import { preferredShell, rememberShell } from './shell-preference.ts'
|
||||
import type { TerminalInventory } from './inventory.ts'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
@@ -83,6 +84,7 @@ export class TerminalView {
|
||||
* @param sessionId - Session owning the terminal.
|
||||
* @param remote - typed terminal Remote operations.
|
||||
* @param gateway - reconnecting stream factory.
|
||||
* @param inventory - Session-owned terminal list shared by all views.
|
||||
* @param id - Host terminal identity, reused when recovering an item from its Session list.
|
||||
* @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
|
||||
* @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
|
||||
@@ -92,8 +94,9 @@ export class TerminalView {
|
||||
private readonly sessionId: SessionId,
|
||||
private readonly remote: TerminalRemote,
|
||||
private readonly gateway: Pick<ClientRemote, '$stream'>,
|
||||
private readonly inventory: TerminalInventory,
|
||||
readonly id: WebTerminalId,
|
||||
private readonly createWhenMissing = true,
|
||||
private createWhenMissing = true,
|
||||
private readonly shellPath?: string,
|
||||
private readonly retain?: (signal: AbortSignal) => Promise<void>,
|
||||
) {}
|
||||
@@ -104,8 +107,9 @@ export class TerminalView {
|
||||
*/
|
||||
mount(): () => void {
|
||||
this.mounted = true
|
||||
if (this.state.getSnapshot().info === undefined) void this.refresh()
|
||||
else this.connect()
|
||||
const state = this.state.getSnapshot()
|
||||
if (state.phase === 'idle') void this.refresh()
|
||||
else if (state.info !== undefined && state.phase !== 'failed' && state.phase !== 'closed') this.connect()
|
||||
return () => {
|
||||
this.mounted = false
|
||||
this.detach()
|
||||
@@ -113,22 +117,24 @@ export class TerminalView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or recover this tab, deduplicating mounts and retries during allocation.
|
||||
* Only a new tab may allocate a shell; listed terminals cannot be silently replaced.
|
||||
* Start or explicitly retry this tab, sharing Session discovery with other views.
|
||||
* New identities allocate directly; confirmed or restored terminals cannot be replaced.
|
||||
* @returns after environment lookup and creation or recovery settle.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
if (this.creation !== undefined) return this.creation
|
||||
if (this.loading !== undefined) return this.loading
|
||||
if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
|
||||
const retry = this.state.getSnapshot().phase !== 'idle'
|
||||
this.patch({ phase: 'loading', error: undefined, issue: undefined })
|
||||
this.loading = (async () => {
|
||||
const [environment, available] = await Promise.all([
|
||||
this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId),
|
||||
this.remote.environment(this.sessionId, this.lifetime.signal),
|
||||
this.createWhenMissing ? undefined : this.inventory.read(retry),
|
||||
])
|
||||
if (this.stopped()) return
|
||||
this.patch({ environment: valueOf(environment) })
|
||||
const info = valueOf(available).find(item => item.id === this.id)
|
||||
const info = available?.get(this.id)
|
||||
if (info !== undefined) this.adopt(info)
|
||||
else if (this.createWhenMissing) {
|
||||
let path = this.shellPath
|
||||
@@ -156,6 +162,7 @@ export class TerminalView {
|
||||
cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
|
||||
}, this.lifetime.signal))
|
||||
if (!this.lifetime.signal.aborted) {
|
||||
this.inventory.update(info)
|
||||
this.adopt(info)
|
||||
}
|
||||
})().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined })
|
||||
@@ -163,6 +170,7 @@ export class TerminalView {
|
||||
}
|
||||
|
||||
private adopt(info: WebTerminalInfo): void {
|
||||
this.createWhenMissing = false
|
||||
this.patch({ info, title: info.title })
|
||||
if (this.retain === undefined) { if (this.mounted && this.closing === undefined) this.connect(); return }
|
||||
void this.retain(this.lifetime.signal).then(() => {
|
||||
@@ -285,7 +293,9 @@ export class TerminalView {
|
||||
if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return
|
||||
try {
|
||||
valueOf(await this.remote.rename(this.sessionId, this.id, title))
|
||||
if (this.stopped()) return
|
||||
const current = this.state.getSnapshot().info
|
||||
if (current !== undefined) this.inventory.update({ ...current, title: title.trim() })
|
||||
this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() })
|
||||
} catch (error) { this.fail(error) }
|
||||
}
|
||||
@@ -302,6 +312,7 @@ export class TerminalView {
|
||||
// Even a refused or lost creation response may leave an allocation to close.
|
||||
await this.creation
|
||||
valueOf(await this.remote.close(this.sessionId, this.id))
|
||||
this.inventory.remove(this.id)
|
||||
this.detach()
|
||||
this.patch({ phase: 'closed', writable: false })
|
||||
})().catch((error: unknown) => { this.closing = undefined; this.fail(error); throw error })
|
||||
@@ -363,6 +374,7 @@ export class TerminalView {
|
||||
continue
|
||||
}
|
||||
if (frame.type !== 'output') {
|
||||
this.inventory.update(frame.info)
|
||||
this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.writable })
|
||||
}
|
||||
if (frame.type !== 'state') {
|
||||
@@ -393,6 +405,7 @@ export class TerminalView {
|
||||
private fail(error: unknown): void {
|
||||
const failure = remoteErrorOf(error)
|
||||
const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : failure?.code === 'terminal/unavailable' ? 'missingTerminal' : undefined
|
||||
if (!this.stopped() && issue === 'missingTerminal') this.inventory.remove(this.id)
|
||||
this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Shared discovery preserves explicit retries, acknowledged changes and connection ownership. */
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { TerminalInventory } from '../src/client/inventory.ts'
|
||||
import type { WebTerminalId, WebTerminalInfo } from '../src/types.ts'
|
||||
|
||||
const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, title: 'bash', shell: { name: 'bash', path: '/bin/bash', args: [] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null }
|
||||
const success = (value: WebTerminalInfo[]): RemoteResult<WebTerminalInfo[]> => ({ ok: true, value })
|
||||
|
||||
it('retains an empty list and a discovery failure until an explicit shared retry', async () => {
|
||||
const error = new RemoteError('gateway/bad-request', 'Host unavailable', {})
|
||||
const load = vi.fn<() => Promise<RemoteResult<WebTerminalInfo[]>>>()
|
||||
.mockResolvedValueOnce({ ok: false, error }).mockResolvedValue(success([]))
|
||||
const inventory = new TerminalInventory(load)
|
||||
try {
|
||||
await expect(inventory.read()).rejects.toBe(error)
|
||||
await expect(inventory.read()).rejects.toBe(error)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
const retry = inventory.read(true)
|
||||
expect(inventory.read(true)).toBe(retry)
|
||||
expect((await retry).size).toBe(0)
|
||||
expect((await inventory.read()).size).toBe(0)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
} finally { await inventory.dispose() }
|
||||
})
|
||||
|
||||
it('merges creation, rename, state frames and closure over an older pending list', async () => {
|
||||
const listed = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
|
||||
const inventory = new TerminalInventory(() => listed.promise)
|
||||
const created = { ...info, id: 'created' as WebTerminalId }
|
||||
const closed = { ...info, id: 'closed' as WebTerminalId }
|
||||
const renamed = { ...info, title: 'Build', state: 'exited' as const, exitCode: 0 }
|
||||
const pending = inventory.read()
|
||||
try {
|
||||
inventory.update(created)
|
||||
inventory.update(renamed)
|
||||
inventory.remove(closed.id)
|
||||
listed.resolve(success([info, closed]))
|
||||
const result = await pending
|
||||
expect([...result.values()]).toEqual([renamed, created])
|
||||
inventory.remove(created.id)
|
||||
expect([...(await inventory.read()).values()]).toEqual([renamed])
|
||||
} finally { listed.resolve(success([])); await inventory.dispose() }
|
||||
})
|
||||
|
||||
it.each(['success', 'failure'] as const)('ignores a previous connection response after replacement: %s', async (outcome) => {
|
||||
const previous = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
|
||||
const current = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
|
||||
const load = vi.fn<() => Promise<RemoteResult<WebTerminalInfo[]>>>()
|
||||
.mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise)
|
||||
const inventory = new TerminalInventory(load)
|
||||
const pending = inventory.read()
|
||||
const next = { ...info, title: 'Current Host' }
|
||||
try {
|
||||
const replacement = inventory.reset()
|
||||
expect(inventory.read()).toBe(replacement)
|
||||
current.resolve(success([next]))
|
||||
expect([...(await inventory.read()).values()]).toEqual([next])
|
||||
if (outcome === 'success') previous.resolve(success([info]))
|
||||
else previous.reject(new Error('Old connection failed'))
|
||||
expect([...(await pending).values()]).toEqual([next])
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
} finally { previous.resolve(success([])); current.resolve(success([])); await inventory.dispose() }
|
||||
})
|
||||
|
||||
it('does not discover unused inventories on reconnect and refreshes a settled inventory once', async () => {
|
||||
const load = vi.fn(async () => success([info]))
|
||||
const inventory = new TerminalInventory(load)
|
||||
try {
|
||||
inventory.update(info)
|
||||
expect(inventory.reset()).toBeUndefined()
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
await inventory.read()
|
||||
const refreshed = inventory.reset()
|
||||
expect(inventory.read()).toBe(refreshed)
|
||||
await refreshed
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
} finally { await inventory.dispose() }
|
||||
})
|
||||
|
||||
it('joins outstanding requests on disposal and rejects their late results', async () => {
|
||||
const listed = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
|
||||
const load = vi.fn(() => listed.promise)
|
||||
const inventory = new TerminalInventory(load)
|
||||
const pending = inventory.read()
|
||||
const rejected = expect(pending).rejects.toThrow('Terminal inventory disposed')
|
||||
let disposed = false
|
||||
const disposing = inventory.dispose().then(() => { disposed = true })
|
||||
try {
|
||||
expect(disposed).toBe(false)
|
||||
inventory.update(info)
|
||||
inventory.remove(info.id)
|
||||
expect(inventory.reset()).toBeUndefined()
|
||||
await expect(inventory.read()).rejects.toThrow('Terminal inventory disposed')
|
||||
listed.resolve(success([info]))
|
||||
await rejected
|
||||
await disposing
|
||||
expect(disposed).toBe(true)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
} finally { listed.resolve(success([])); await disposing }
|
||||
})
|
||||
|
||||
it('preserves a non-Error transport rejection as the cached failure cause', async () => {
|
||||
const load = vi.fn<() => Promise<RemoteResult<WebTerminalInfo[]>>>().mockRejectedValue('offline')
|
||||
const inventory = new TerminalInventory(load)
|
||||
try {
|
||||
await expect(inventory.read()).rejects.toMatchObject({ message: 'Terminal discovery failed', cause: 'offline' })
|
||||
} finally { await inventory.dispose() }
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { RemoteMock, streamMethod, type StreamHandle } from '@deepseek-ai/dsh-re
|
||||
import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
|
||||
import { TerminalInventory } from '../src/client/inventory.ts'
|
||||
import type { TerminalInput, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts'
|
||||
|
||||
const sessionId = 'session' as SessionId
|
||||
@@ -66,7 +67,9 @@ function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void, inp
|
||||
},
|
||||
}
|
||||
cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) })
|
||||
const model = new TerminalView(sessionId, remote, gateway, info.id)
|
||||
const inventory = new TerminalInventory(() => remote.list(sessionId))
|
||||
cleanups.push(() => inventory.dispose())
|
||||
const model = new TerminalView(sessionId, remote, gateway, inventory, info.id)
|
||||
cleanups.push(() => model.dispose())
|
||||
return { model, remote, gateway, options, streams, generation, attachments, sent }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts'
|
||||
import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
|
||||
import { TerminalInventory } from '../src/client/inventory.ts'
|
||||
import { TerminalCloseRequests } from '../src/client/close-requests.ts'
|
||||
import { TerminalBindings } from '../src/client/bindings.ts'
|
||||
import * as TerminalClient from '../src/client/index.ts'
|
||||
@@ -52,23 +53,143 @@ function fixture() {
|
||||
})),
|
||||
}
|
||||
const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
|
||||
function view() {
|
||||
const model = new TerminalView(sessionId, remote, gateway, info.id)
|
||||
function view(restored = false) {
|
||||
const inventory = new TerminalInventory(() => remote.list(sessionId))
|
||||
cleanups.push(() => inventory.dispose())
|
||||
const model = new TerminalView(sessionId, remote, gateway, inventory, info.id, !restored)
|
||||
cleanups.push(() => model.dispose())
|
||||
return model
|
||||
}
|
||||
async function service() {
|
||||
async function service(connected = false) {
|
||||
const ctx = new Context()
|
||||
ctx.provide('remote', { ...gateway, terminal: remote } as never)
|
||||
ctx.provide('remote.terminal', remote)
|
||||
ctx.provide('connection', { generation: createSnapshotStore(connected ? {} : undefined) } as never)
|
||||
const fiber = ctx.plugin(TerminalClient)
|
||||
cleanups.push(async () => { await ctx.fiber.dispose() })
|
||||
await fiber
|
||||
return { service: ctx.webTerminals, dispose: () => fiber.dispose() }
|
||||
return { service: ctx.webTerminals, ctx, dispose: () => fiber.dispose() }
|
||||
}
|
||||
return { remote, view, service }
|
||||
}
|
||||
|
||||
it('shares one Session list across concurrent and later restored terminal views', async () => {
|
||||
storage()
|
||||
const h = fixture()
|
||||
const { service } = await h.service()
|
||||
const terminals = Array.from({ length: 8 }, (_, index) => ({ ...info, id: `restored-${index}` as WebTerminalId }))
|
||||
vi.mocked(h.remote.list).mockResolvedValue(success(terminals))
|
||||
const first = terminals.slice(0, 4).map((terminal, index) => service.view(sessionId, `tab-${index}`, `content-${index}`, terminal.id))
|
||||
await Promise.all(first.map(model => model.refresh()))
|
||||
for (const [index, terminal] of terminals.slice(4).entries()) {
|
||||
const model = service.view(sessionId, `later-${index}`, `later-${index}`, terminal.id)
|
||||
await model.refresh()
|
||||
expect(model.state.getSnapshot().info?.id).toBe(terminal.id)
|
||||
}
|
||||
expect(h.remote.list).toHaveBeenCalledOnce()
|
||||
expect(h.remote.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates new terminal identities without reading the Session list', async () => {
|
||||
storage()
|
||||
const h = fixture()
|
||||
const { service } = await h.service()
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const model = service.view(sessionId, `new-${index}`, `new-${index}`)
|
||||
await model.refresh()
|
||||
}
|
||||
expect(h.remote.create).toHaveBeenCalledTimes(8)
|
||||
expect(h.remote.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a missing terminal failure across remounts without retrying discovery', async () => {
|
||||
storage()
|
||||
const h = fixture()
|
||||
const { service } = await h.service()
|
||||
const model = service.view(sessionId, 'missing', 'missing', info.id)
|
||||
await model.refresh()
|
||||
const failed = model.state.getSnapshot()
|
||||
expect(failed.issue).toBe('missingTerminal')
|
||||
for (let index = 0; index < 5; index++) {
|
||||
model.mount()()
|
||||
await setImmediate()
|
||||
}
|
||||
expect(model.state.getSnapshot()).toBe(failed)
|
||||
expect(h.remote.list).toHaveBeenCalledOnce()
|
||||
expect(h.remote.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes each discovered Session once per reconnect and ignores the initial connection notification', async () => {
|
||||
storage()
|
||||
const h = fixture()
|
||||
const { service, ctx } = await h.service()
|
||||
vi.mocked(h.remote.list).mockResolvedValue(success([info]))
|
||||
await service.view(sessionId, 'a', 'a', info.id).refresh()
|
||||
await service.view(sessionId, 'b', 'b', info.id).refresh()
|
||||
await service.view('new-session' as SessionId, 'new', 'new').refresh()
|
||||
ctx.emit('connection/reset')
|
||||
expect(h.remote.list).toHaveBeenCalledOnce()
|
||||
ctx.emit('connection/reset')
|
||||
await service.recover(sessionId)
|
||||
expect(h.remote.list).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(h.remote.list).mock.calls.every(call => call[0] === sessionId)).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes after late plugin activation and retains a failed refresh for explicit retry', async () => {
|
||||
const h = fixture()
|
||||
const { service, ctx, dispose } = await h.service(true)
|
||||
vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
|
||||
expect(await service.recover(sessionId)).toEqual([info])
|
||||
vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Offline'))
|
||||
ctx.emit('connection/reset')
|
||||
await expect(service.recover(sessionId)).rejects.toThrow('Offline')
|
||||
await expect(service.recover(sessionId)).rejects.toThrow('Offline')
|
||||
expect(h.remote.list).toHaveBeenCalledTimes(2)
|
||||
vi.mocked(h.remote.list).mockResolvedValueOnce(success([]))
|
||||
expect(await service.recover(sessionId, true)).toEqual([])
|
||||
await dispose()
|
||||
ctx.emit('connection/reset')
|
||||
expect(h.remote.list).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('shares failed discovery between views and keeps a disposed view from cancelling another waiter', async () => {
|
||||
storage()
|
||||
const h = fixture()
|
||||
const { service } = await h.service()
|
||||
vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Offline'))
|
||||
const first = service.view(sessionId, 'a', 'a', info.id)
|
||||
await first.refresh()
|
||||
const second = service.view(sessionId, 'b', 'b', info.id)
|
||||
await second.refresh()
|
||||
expect(h.remote.list).toHaveBeenCalledOnce()
|
||||
expect(second.state.getSnapshot().error).toBe('Offline')
|
||||
const listed = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
|
||||
vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise)
|
||||
const pending = Promise.all([first.refresh(), second.refresh()])
|
||||
try {
|
||||
await first.dispose()
|
||||
const disposed = first.state.getSnapshot()
|
||||
listed.resolve(success([info]))
|
||||
await pending
|
||||
expect(first.state.getSnapshot()).toBe(disposed)
|
||||
expect(second.state.getSnapshot().info?.id).toBe(info.id)
|
||||
expect(h.remote.list).toHaveBeenCalledTimes(2)
|
||||
} finally { listed.resolve(success([])); await pending }
|
||||
})
|
||||
|
||||
it('joins a failed reconnect lookup during plugin disposal without publishing another failure', async () => {
|
||||
const h = fixture()
|
||||
const { service, ctx, dispose } = await h.service(true)
|
||||
await service.recover(sessionId)
|
||||
const listed = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
|
||||
vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise)
|
||||
ctx.emit('connection/reset')
|
||||
const disposing = dispose()
|
||||
listed.reject(new Error('Connection closed during disposal'))
|
||||
await disposing
|
||||
expect(h.remote.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('starts automatically and deduplicates overlapping mounts and refreshes', async () => {
|
||||
const h = fixture()
|
||||
const model = h.view()
|
||||
@@ -90,7 +211,7 @@ it('starts automatically and deduplicates overlapping mounts and refreshes', asy
|
||||
it('recovers a listed process without creating another, including its title and screen', async () => {
|
||||
const h = fixture()
|
||||
vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, title: 'Build' }]))
|
||||
const model = h.view()
|
||||
const model = h.view(true)
|
||||
model.mount()
|
||||
await model.refresh()
|
||||
expect(h.remote.create).not.toHaveBeenCalled()
|
||||
@@ -103,8 +224,10 @@ it('does not recreate a recovered terminal that disappeared after the recovery l
|
||||
const { service } = await h.service()
|
||||
vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
|
||||
expect(await service.recover(sessionId)).toEqual([info])
|
||||
vi.mocked(h.remote.retain).mockImplementation(() => { throw new RemoteError('terminal/unavailable', 'gone', {}) })
|
||||
const model = service.view(sessionId, 'recovered', 'recovered', info.id)
|
||||
await model.refresh()
|
||||
await expect.poll(() => model.state.getSnapshot().issue).toBe('missingTerminal')
|
||||
expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false })
|
||||
expect(model.state.getSnapshot().issue).toBe('missingTerminal')
|
||||
expect(h.remote.create).not.toHaveBeenCalled()
|
||||
@@ -259,15 +382,15 @@ it('queries current held views after a slow recovery response and reports discov
|
||||
const listed = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
|
||||
vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise)
|
||||
const recovering = service.recover(sessionId)
|
||||
vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
|
||||
const model = service.view(sessionId, 'recovered', 'recovered', info.id)
|
||||
await model.refresh()
|
||||
listed.resolve(success([info]))
|
||||
const loading = model.refresh()
|
||||
try { expect(h.remote.list).toHaveBeenCalledOnce() }
|
||||
finally { listed.resolve(success([info])); await loading }
|
||||
expect(await recovering).toEqual([])
|
||||
expect(service.view(sessionId, 'recovered', 'recovered', info.id)).toBe(model)
|
||||
expect(model.id).toBe(info.id)
|
||||
vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable'))
|
||||
await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable')
|
||||
await expect(service.recover(sessionId, true)).rejects.toThrow('Session unavailable')
|
||||
})
|
||||
|
||||
it('ignores an unknown tab and retries only saved close requests', async () => {
|
||||
@@ -703,7 +826,9 @@ it('ignores a delayed retention failure after the restored view was disposed', a
|
||||
vi.mocked(h.remote.list).mockResolvedValue(success([info]))
|
||||
const retained = Promise.withResolvers<undefined>()
|
||||
const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
|
||||
const model = new TerminalView(sessionId, h.remote, gateway, info.id, false, undefined, () => retained.promise)
|
||||
const inventory = new TerminalInventory(() => h.remote.list(sessionId))
|
||||
cleanups.push(() => inventory.dispose())
|
||||
const model = new TerminalView(sessionId, h.remote, gateway, inventory, info.id, false, undefined, () => retained.promise)
|
||||
cleanups.push(() => model.dispose())
|
||||
await model.refresh()
|
||||
await model.dispose()
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"src/client/retention.ts",
|
||||
"src/client/shell-preference.ts",
|
||||
"src/types.ts",
|
||||
"src/client/close-requests.ts"
|
||||
"src/client/close-requests.ts",
|
||||
"src/client/inventory.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
@@ -38,6 +39,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/deque"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-workspace-controller",
|
||||
"description": "Workspace Remote commands and reconnect-safe state transport",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-workspace-files",
|
||||
"description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment-local",
|
||||
"description": "Private content-addressed DSH_HOME attachment storage",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment",
|
||||
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-app-boot",
|
||||
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cmdline",
|
||||
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-config-editor",
|
||||
"description": "Persist plugin configuration through profile patches and Loader reconciliation",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-hmr",
|
||||
"description": "Coordinated module and profile configuration hot reload",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-plugin-manager",
|
||||
"description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-plugin-market",
|
||||
"description": "Configured plugin market indexes for the Web Discover page and the plugin_market agent tool",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-browser-use",
|
||||
"description": "Exclusive named browser-use provider registration",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp-app",
|
||||
"description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-base",
|
||||
"description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-headless",
|
||||
"description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sdk-app",
|
||||
"description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sdk-minimal",
|
||||
"description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-app",
|
||||
"description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, bash runtime variables, URL line)",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-connection",
|
||||
"description": "Authenticated RPC transport and generation lifecycle",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-file-upload",
|
||||
"description": "Agent-scoped browser file upload, streaming intake, and staged receipt service",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Web client graph synchronization and rebuilt-bundle reload transport",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-locale",
|
||||
"description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-modules",
|
||||
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-product-analytics",
|
||||
"description": "Desktop product event collection and authenticated Host reporting",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-resources",
|
||||
"description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-shortcuts",
|
||||
"description": "Application keyboard command registry and physical-key routing",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-store",
|
||||
"description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
|
||||
"description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-approval",
|
||||
"description": "Approval composer takeover over the scoped Remote Event waterfall",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-attachment",
|
||||
"description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-brand-official",
|
||||
"description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-chat",
|
||||
"description": "Chat Conversation target, node definitions, renderers, and details surface",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-commands",
|
||||
"description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-conversation",
|
||||
"description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-deliverables",
|
||||
"description": "Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-directory-picker-browse",
|
||||
"description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
|
||||
"description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-dockkit",
|
||||
"description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-goal",
|
||||
"description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-input-trigger",
|
||||
"description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-jobs",
|
||||
"description": "Session-header background-job list with on-demand streaming record panels",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-layout",
|
||||
"description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-message-feedback",
|
||||
"description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-model-selection",
|
||||
"description": "Model selection over the shared model catalog, Session projection, and session.selectModel",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-open-in-app",
|
||||
"description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-permission-presets",
|
||||
"description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plan",
|
||||
"description": "Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plugin-manager",
|
||||
"description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plugin-market",
|
||||
"description": "Discover tab of the dsh web Plugins page: browse configured plugin market indexes and install their bundles",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-primitives",
|
||||
"description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-reference",
|
||||
"description": "Unified Web @file and @session reference source",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-renderer",
|
||||
"description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-schedule",
|
||||
"description": "Host task management page and Session reminder catalog",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-secret-mask",
|
||||
"description": "Web display masking for recognized credentials in sent user text",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-session",
|
||||
"description": "Session Controller adapter for React and session-scoped slots",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-account",
|
||||
"description": "Manage DeepSeek login and open Platform billing pages",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-agent-loop",
|
||||
"description": "Settings page of the agent loop on the dsh web client's Plugins page: the parallel tool-call cap of the agent-loop namespace",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-general",
|
||||
"description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice",
|
||||
"version": "0.2.1-alpha.3",
|
||||
"version": "0.2.1-alpha.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: f4817eb2e3537ed9
|
||||
zh: e589c193fa85473f
|
||||
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
|
||||
en: eac663a25c60adf9
|
||||
zh: 24450ef377a79e01
|
||||
en: 3af73644655c8b7b
|
||||
zh: d689eba772100c63
|
||||
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
|
||||
en: 9d533c65c82cd4a4
|
||||
zh: 0a5da334e127c035
|
||||
|
||||
@@ -53,12 +53,14 @@ A blank key field offers a saved credential when one is detected, with no status
|
||||
|
||||
An endpoint without a fixed catalog default shows the provider's `baseURLEnv` configuration names below the input. A profile endpoint remains the first placeholder choice.
|
||||
|
||||
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits.
|
||||
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits.
|
||||
|
||||
The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`.
|
||||
|
||||
Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process.
|
||||
|
||||
A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control.
|
||||
|
||||
### Adding and deleting providers
|
||||
|
||||
**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
|
||||
|
||||
@@ -53,12 +53,14 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求
|
||||
|
||||
目录未提供固定默认端点时,输入框下方显示提供方的 `baseURLEnv` 配置变量名。Profile 中的端点仍是占位符的首选。
|
||||
|
||||
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。
|
||||
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。
|
||||
|
||||
`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。
|
||||
|
||||
展开**自定义设置 → 模型选项**编辑模型。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。
|
||||
|
||||
手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。
|
||||
|
||||
### 新增与删除提供商
|
||||
|
||||
**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
|
||||
|
||||
Loaded 100 of 412 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user