Merge branch 'master' into fix/4781-dedupe-open-with-apps
No files matched your search
@@ -28,6 +28,7 @@ import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import * as ToolJobs from '@deepseek-ai/dsh-tool-jobs'
|
||||
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as CordisInspectProviders from '@deepseek-ai/dsh-tool-cordis/host'
|
||||
|
||||
/**
|
||||
* With-key PTC mode proof: a real model receives only `run_code`, composes two
|
||||
@@ -280,6 +281,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
it('uses runtime inspection results directly through PTC', async () => {
|
||||
ctx = await typedPtcModeHarness()
|
||||
await ctx.plugin(CordisHostRunner)
|
||||
await ctx.plugin(CordisInspectProviders)
|
||||
await ctx.plugin(ToolCordis)
|
||||
const agent = {
|
||||
id: SessionId('ptc-cordis'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -668,6 +668,80 @@ describe('product Bundle and user-preset intersection', () => {
|
||||
}, 120_000)
|
||||
})
|
||||
|
||||
describe('a user preset copied from a shipped one', () => {
|
||||
/** The JSON text a `cordis_inspect_*` result renders. */
|
||||
function resultText(result: { content: ReadonlyArray<{ type: string; text?: string }> }): string {
|
||||
return result.content.filter(part => part.type === 'text').map(part => part.text ?? '').join('')
|
||||
}
|
||||
|
||||
it('mounts beside the shipped `cordis` preset and reads the shared Host inspect providers', async () => {
|
||||
// The Host inspect providers are one process-global set registered by the
|
||||
// host composition (`@deepseek-ai/dsh-tool-cordis/host`); each preset's
|
||||
// `tool-cordis` row only registers the tools. Before that split the copy
|
||||
// failed to mount: its row re-registered provider "Service".
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-copied-preset-'))
|
||||
const userRoot = join(root, 'presets')
|
||||
await cp(join(SHIPPED_PRESET_ROOT, 'cordis'), join(userRoot, 'cordis-copy'), { recursive: true })
|
||||
const settingsFile = join(root, 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
const copyCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false },
|
||||
}])
|
||||
try {
|
||||
const shipped = await copyCtx.agents.create({
|
||||
sessionId: SessionId(`preset-cordis-shipped-${randomUUID()}`),
|
||||
setup: agentCtx => copyCtx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined),
|
||||
})
|
||||
const copied = await copyCtx.agents.create({
|
||||
sessionId: SessionId(`preset-cordis-copy-${randomUUID()}`),
|
||||
setup: agentCtx => copyCtx.agentPresets.mount(agentCtx, 'cordis-copy').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
for (const handle of [shipped, copied]) {
|
||||
expect(toolNames(copyCtx, handle.agent)).toEqual(expect.arrayContaining([
|
||||
'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager',
|
||||
]))
|
||||
}
|
||||
const signal = new AbortController().signal
|
||||
const listed = await copyCtx.tools.execute({
|
||||
callId: ToolCallId('copied-preset-inspect-list'),
|
||||
name: 'cordis_inspect_list',
|
||||
arguments: {},
|
||||
signal,
|
||||
agent: copied.agent,
|
||||
})
|
||||
expect(listed.isError).toBe(false)
|
||||
const providers = (JSON.parse(resultText(listed)) as { providers: Array<{ id: string; platform: string }> }).providers
|
||||
expect(providers.filter(provider => provider.platform === 'host').map(provider => provider.id))
|
||||
.toEqual(['Service', 'Event', 'Builtin', 'Tool'])
|
||||
|
||||
// The `Tool` provider is the one built over the host context: it
|
||||
// answers with the shared registry's view of the REQUESTING agent,
|
||||
// so the copy sees its own preset's tools, not the host's empty set.
|
||||
const queried = await copyCtx.tools.execute({
|
||||
callId: ToolCallId('copied-preset-inspect-tools'),
|
||||
name: 'cordis_inspect_query',
|
||||
arguments: { platform: 'host', provider: 'Tool', method: 'listTools' },
|
||||
signal,
|
||||
agent: copied.agent,
|
||||
})
|
||||
expect(queried.isError).toBe(false)
|
||||
const tools = (JSON.parse(resultText(queried)) as { data: { tools: Array<{ name: string }> } }).data.tools
|
||||
expect(tools.map(tool => tool.name)).toEqual(expect.arrayContaining([
|
||||
'bash', 'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager',
|
||||
]))
|
||||
} finally {
|
||||
await copied.dispose()
|
||||
await shipped.dispose()
|
||||
}
|
||||
} finally {
|
||||
await copyCtx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
|
||||
describe('a switch survives the session', () => {
|
||||
it('records the choice so the log states what the agent runs', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
@@ -43,5 +43,4 @@
|
||||
- button "添加模型"
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方"
|
||||
- button "添加自定义提供方"
|
||||
- button "添加模型提供商"
|
||||
@@ -8,7 +8,7 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: openrouter
|
||||
@@ -24,5 +24,4 @@
|
||||
- button "编辑 acme-gateway": 编辑
|
||||
- button "删除 acme-gateway": 删除
|
||||
- alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks"
|
||||
- button "添加提供方"
|
||||
- button "添加自定义提供方"
|
||||
- button "添加模型提供商"
|
||||
@@ -8,7 +8,7 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
@@ -25,7 +25,7 @@
|
||||
- group:
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: 提供方默认
|
||||
- /placeholder: 提供商默认
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
@@ -59,5 +59,4 @@
|
||||
- text: Acme 网关 自定义
|
||||
- button "编辑 Acme 网关 (acme-gateway)": 编辑
|
||||
- button "删除 Acme 网关 (acme-gateway)": 删除
|
||||
- button "添加提供方"
|
||||
- button "添加自定义提供方"
|
||||
- button "添加模型提供商"
|
||||
@@ -8,7 +8,7 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- status: 已保存 minimax-cn。
|
||||
- list:
|
||||
- listitem:
|
||||
@@ -16,5 +16,4 @@
|
||||
- img "API 密钥已配置"
|
||||
- button "编辑 minimax-cn": 编辑
|
||||
- button "删除 minimax-cn": 删除
|
||||
- button "添加提供方"
|
||||
- button "添加自定义提供方"
|
||||
- button "添加模型提供商"
|
||||
@@ -8,7 +8,7 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
@@ -33,9 +33,9 @@
|
||||
- text: https://gateway.acme.example/v1
|
||||
- text: API 协议
|
||||
- combobox "API 协议":
|
||||
- option "openai-completions" [selected]
|
||||
- option "openai-responses"
|
||||
- option "anthropic-messages"
|
||||
- option "OpenAI Chat Completions" [selected]
|
||||
- option "OpenAI Responses"
|
||||
- option "Anthropic Messages"
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
@@ -62,5 +62,4 @@
|
||||
- button "添加模型"
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方"
|
||||
- button "添加自定义提供方"
|
||||
- button "添加模型提供商"
|
||||
@@ -8,7 +8,7 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
@@ -19,5 +19,4 @@
|
||||
- text: Acme Gateway 自定义
|
||||
- button "编辑 Acme Gateway (acme-gateway)": 编辑
|
||||
- button "删除 Acme Gateway (acme-gateway)": 删除
|
||||
- button "添加提供方"
|
||||
- button "添加自定义提供方"
|
||||
- button "添加模型提供商"
|
||||
@@ -8,52 +8,57 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list
|
||||
- text: 提供方
|
||||
- combobox "提供方":
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
- option "azure-openai-responses"
|
||||
- option "baseten"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
- option "cloudflare-workers-ai"
|
||||
- option "deepseek"
|
||||
- option "fireworks"
|
||||
- option "github-copilot"
|
||||
- option "google"
|
||||
- option "google-vertex"
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "minimax"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
- option "moonshotai"
|
||||
- option "moonshotai-cn"
|
||||
- option "nvidia"
|
||||
- option "openai"
|
||||
- option "openai-codex"
|
||||
- option "opencode"
|
||||
- option "opencode-go"
|
||||
- option "openrouter"
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "qwen-token-plan-individual"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
- option "xiaomi"
|
||||
- option "xiaomi-token-plan-ams"
|
||||
- option "xiaomi-token-plan-cn"
|
||||
- option "xiaomi-token-plan-sgp"
|
||||
- option "zai"
|
||||
- option "zai-coding-cn"
|
||||
- text: API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥,或留空使用环境认证
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- tablist "添加方式":
|
||||
- tab "第三方模型提供商" [selected]
|
||||
- tab "自定义模型 API"
|
||||
- paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。
|
||||
- tabpanel "第三方模型提供商":
|
||||
- text: 提供商
|
||||
- combobox "提供商":
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
- option "azure-openai-responses"
|
||||
- option "baseten"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
- option "cloudflare-workers-ai"
|
||||
- option "deepseek"
|
||||
- option "fireworks"
|
||||
- option "github-copilot"
|
||||
- option "google"
|
||||
- option "google-vertex"
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "minimax"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
- option "moonshotai"
|
||||
- option "moonshotai-cn"
|
||||
- option "nvidia"
|
||||
- option "openai"
|
||||
- option "openai-codex"
|
||||
- option "opencode"
|
||||
- option "opencode-go"
|
||||
- option "openrouter"
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "qwen-token-plan-individual"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
- option "xiaomi"
|
||||
- option "xiaomi-token-plan-ams"
|
||||
- option "xiaomi-token-plan-cn"
|
||||
- option "xiaomi-token-plan-sgp"
|
||||
- option "zai"
|
||||
- option "zai-coding-cn"
|
||||
- text: API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥,或留空使用环境认证
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
@@ -1,7 +1,7 @@
|
||||
- dialog "选择要添加的模型":
|
||||
- heading "选择要添加的模型" [level=2]
|
||||
- button "关闭"
|
||||
- paragraph: 以下是模型提供方的可用模型,勾选要添加的模型。
|
||||
- paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。
|
||||
- searchbox "搜索模型"
|
||||
- button "取消全选"
|
||||
- list:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
@@ -56,5 +56,4 @@
|
||||
- button "添加模型"
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方"
|
||||
- button "添加自定义提供方"
|
||||
- button "添加模型提供商"
|
||||
@@ -8,7 +8,7 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
@@ -50,5 +50,4 @@
|
||||
- button "添加模型"
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方"
|
||||
- button "添加自定义提供方"
|
||||
- button "添加模型提供商"
|
||||
@@ -8,56 +8,61 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
- img "API 密钥缺失"
|
||||
- button "编辑 DeepSeek (deepseek-official)": 编辑
|
||||
- text: 提供方
|
||||
- combobox "提供方":
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
- option "azure-openai-responses"
|
||||
- option "baseten"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
- option "cloudflare-workers-ai"
|
||||
- option "deepseek"
|
||||
- option "fireworks"
|
||||
- option "github-copilot"
|
||||
- option "google"
|
||||
- option "google-vertex"
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "minimax"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
- option "moonshotai"
|
||||
- option "moonshotai-cn"
|
||||
- option "nvidia"
|
||||
- option "openai"
|
||||
- option "openai-codex"
|
||||
- option "opencode"
|
||||
- option "opencode-go"
|
||||
- option "openrouter"
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "qwen-token-plan-individual"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
- option "xiaomi"
|
||||
- option "xiaomi-token-plan-ams"
|
||||
- option "xiaomi-token-plan-cn"
|
||||
- option "xiaomi-token-plan-sgp"
|
||||
- option "zai"
|
||||
- option "zai-coding-cn"
|
||||
- text: API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥,或留空使用环境认证
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- tablist "添加方式":
|
||||
- tab "第三方模型提供商" [selected]
|
||||
- tab "自定义模型 API"
|
||||
- paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。
|
||||
- tabpanel "第三方模型提供商":
|
||||
- text: 提供商
|
||||
- combobox "提供商":
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
- option "azure-openai-responses"
|
||||
- option "baseten"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
- option "cloudflare-workers-ai"
|
||||
- option "deepseek"
|
||||
- option "fireworks"
|
||||
- option "github-copilot"
|
||||
- option "google"
|
||||
- option "google-vertex"
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "minimax"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
- option "moonshotai"
|
||||
- option "moonshotai-cn"
|
||||
- option "nvidia"
|
||||
- option "openai"
|
||||
- option "openai-codex"
|
||||
- option "opencode"
|
||||
- option "opencode-go"
|
||||
- option "openrouter"
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "qwen-token-plan-individual"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
- option "xiaomi"
|
||||
- option "xiaomi-token-plan-ams"
|
||||
- option "xiaomi-token-plan-cn"
|
||||
- option "xiaomi-token-plan-sgp"
|
||||
- option "zai"
|
||||
- option "zai-coding-cn"
|
||||
- text: API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥,或留空使用环境认证
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
@@ -54,19 +54,21 @@ describe('web e2e: repairs a stored provider after catalog drift', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => {
|
||||
it('shows the failed provider beside healthy providers and keeps the add action usable', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'models-settings-recovery'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByRole('button', { name: '编辑 acme-gateway', exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByText(CUSTOM_FAILURE, { exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).isEnabled()).toBe(true)
|
||||
await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
|
||||
await dialog.getByRole('button', { name: '添加提供方', exact: true }).click()
|
||||
await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn')
|
||||
await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).click()
|
||||
// Both modes stay offered beside a failed route: the card opens on the
|
||||
// third-party mode with the custom-API segment enabled.
|
||||
expect(await dialog.getByRole('tab', { name: '自定义模型 API' }).isEnabled()).toBe(true)
|
||||
await dialog.getByLabel('提供商', { exact: true }).selectOption('minimax-cn')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor()
|
||||
expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Web e2e scenario: the Models settings page end to end through the real
|
||||
// wire — the add card offers the dormant pi-ai catalog, a blank key saves a
|
||||
// wire — the one add card offers the dormant pi-ai catalog on its third-party
|
||||
// mode and the create form on its custom-API mode, a blank key saves a
|
||||
// reference-free profile for provider-native auth, and typing an API key later
|
||||
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
|
||||
// while the settings document records only that reference. Each saved row
|
||||
@@ -74,16 +75,20 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '模型' }).click()
|
||||
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await dialog.getByText('填入各提供商的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
|
||||
// The dormant pi-ai adapter contributes its whole installed catalog; no
|
||||
// provider is configured yet, so the page is one add button.
|
||||
const add = dialog.getByRole('button', { name: '添加提供方' })
|
||||
const add = dialog.getByRole('button', { name: '添加模型提供商' })
|
||||
await add.waitFor({ timeout: 10_000 })
|
||||
// The button enables once the dormant catalog lands in the join.
|
||||
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await add.click()
|
||||
const pick = dialog.getByLabel('提供方')
|
||||
// The card opens on the third-party mode, whose panel carries the select.
|
||||
const modes = dialog.getByRole('tablist', { name: '添加方式' })
|
||||
await modes.waitFor({ timeout: 10_000 })
|
||||
expect(await modes.getByRole('tab', { name: '第三方模型提供商' }).getAttribute('aria-selected')).toBe('true')
|
||||
const pick = dialog.getByLabel('提供商', { exact: true })
|
||||
await pick.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
|
||||
const options = await pick.locator('option').allTextContents()
|
||||
@@ -239,22 +244,29 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
it('declares a route the adapter does not ship', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
|
||||
await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await declare.click()
|
||||
await dialog.getByLabel('Provider ID').fill('acme-gateway')
|
||||
await dialog.getByLabel('显示名称').fill('Acme Gateway')
|
||||
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
|
||||
const add = dialog.getByRole('button', { name: '添加模型提供商' })
|
||||
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await add.click()
|
||||
// The custom-API mode is the second segment of the one add card; its
|
||||
// purpose line replaces the catalog one.
|
||||
await dialog.getByRole('tab', { name: '自定义模型 API' }).click()
|
||||
await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 })
|
||||
// The third-party panel stays mounted, hidden, beside this one, so the
|
||||
// form is addressed through its own panel.
|
||||
const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' })
|
||||
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.
|
||||
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
|
||||
await dialog.getByRole('button', { name: '添加模型' }).click()
|
||||
await dialog.getByLabel('模型 ID 1').fill('acme-large')
|
||||
await dialog.getByRole('button', { name: '模型选项 1' }).click()
|
||||
expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
|
||||
await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
|
||||
await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
|
||||
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()
|
||||
await custom.getByRole('button', { name: '创建提供商', exact: true }).click()
|
||||
|
||||
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -99,7 +99,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型' }).click()
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
|
||||
await deepSeekRow.waitFor({ timeout: 10_000 })
|
||||
await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
||||
@@ -199,7 +199,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型' }).click()
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
const deepSeek = settings.getByText('DeepSeek', { exact: true }).first()
|
||||
await deepSeek.waitFor({ timeout: 10_000 })
|
||||
await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
||||
@@ -233,7 +233,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
for (let index = 0; index < 2; index++) {
|
||||
await settings.getByRole('button', { name: /删除模型/ }).first().click()
|
||||
}
|
||||
await settings.getByRole('button', { name: '添加模型' }).click()
|
||||
await settings.getByRole('button', { name: '添加模型', exact: true }).click()
|
||||
const customModelId = settings.getByLabel('模型 ID 1')
|
||||
await customModelId.fill('private-preview')
|
||||
await settings.getByLabel('显示名称 1').fill('Private Preview')
|
||||
|
||||
@@ -54,14 +54,14 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
// Dismissing the onboarding step leaves Settings closed, so enter the
|
||||
// Models section explicitly before exercising its normal cards.
|
||||
await settings.getByRole('button', { name: '模型' }).click()
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
|
||||
await setupKey.waitFor({ timeout: 10_000 })
|
||||
|
||||
const add = settings.getByRole('button', { name: '添加提供方' })
|
||||
const add = settings.getByRole('button', { name: '添加模型提供商' })
|
||||
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await add.click()
|
||||
const pick = settings.getByLabel('提供方')
|
||||
const pick = settings.getByLabel('提供商', { exact: true })
|
||||
await pick.waitFor({ timeout: 10_000 })
|
||||
await pick.selectOption('minimax-cn')
|
||||
await expect.poll(
|
||||
@@ -72,7 +72,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
|
||||
// Cancelling the setup card must not close the independent add-provider
|
||||
// draft beside it.
|
||||
await settings.getByRole('button', { name: '取消', exact: true }).first().click()
|
||||
expect(await settings.getByLabel('提供方').count()).toBe(1)
|
||||
expect(await settings.getByLabel('提供商', { exact: true }).count()).toBe(1)
|
||||
await expect.poll(
|
||||
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
|
||||
{ timeout: 10_000 },
|
||||
@@ -115,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
|
||||
// setup card over a user who already has somewhere to send a request.
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型' }).click()
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
|
||||
expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0)
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture)
|
||||
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
|
||||
// Section switch: aria-current moves (the Models page itself has its own scenario file).
|
||||
await dialog.getByRole('button', { name: '模型' }).click()
|
||||
await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await expect.poll(() => dialog.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
|
||||
// Built-in plugins: the read-only Plugin list, a projection of the same
|
||||
// assembled Loader tree, shown as the section's one page; management and
|
||||
@@ -143,7 +143,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
// One contribution shows as the page itself, without a tab row.
|
||||
expect(await dialog.getByRole('tab').count()).toBe(0)
|
||||
expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull()
|
||||
expect(await dialog.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current')).toBeNull()
|
||||
const pluginsSnapshot = await captureStableAria(
|
||||
page,
|
||||
PLUGIN_ROW_SELECTOR,
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
| merge | 合并 | | | |
|
||||
| message | 消息 | | | |
|
||||
| mod | 模组 | | | |
|
||||
| model provider | 模型提供方 | | | |
|
||||
| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 |
|
||||
| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |
|
||||
| module | 模块 | | | |
|
||||
| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |
|
||||
@@ -168,7 +168,7 @@
|
||||
| plugin | 插件 | | | |
|
||||
| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |
|
||||
| prompt | 提示词 | | | |
|
||||
| provider | 提供方 | | | |
|
||||
| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 |
|
||||
| provider-neutral | 提供方无关 | | 提供方中立 | |
|
||||
| quality gate | 质量门禁 | | | |
|
||||
| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |
|
||||
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 76 KiB |
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/user/guide/providers.md
|
||||
providers.md: e578ff96a373c96ebe646126d6c60d7bcc62bbc1
|
||||
providers.zh.md: fd0859f0b235efb5fe8ecc3c248a2ac2bbaeb495
|
||||
providers.md: ef016ef39d5e34f3ce3dd2feb6213b9597668592
|
||||
providers.zh.md: 15fd229ce222a020e897f08c15afcd843791a3bb
|
||||
@@ -8,21 +8,21 @@ This guide assumes you started the Web UI through the [root README](../../../REA
|
||||
|
||||
Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter the key and save it.
|
||||
|
||||

|
||||

|
||||
|
||||
Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference.
|
||||
|
||||
## Add a built-in provider
|
||||
## Add a third-party provider
|
||||
|
||||
Choose **Add provider** and pick a provider dsh ships with; the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM. Enter its API key and save. The installed catalog supplies the endpoint, protocol, and model list.
|
||||
Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list.
|
||||
|
||||
Providers that sign in with OAuth, such as Codex, are not supported here yet.
|
||||
|
||||
## Add a custom provider
|
||||
## Add a custom model API
|
||||
|
||||
Choose **Add a custom provider** for a company gateway, self-hosted server, or provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the form offers three: `openai-completions` for OpenAI Chat Completions, `openai-responses` for the OpenAI Responses API, and `anthropic-messages` for the Anthropic Messages API. A provider speaks one protocol, so a gateway that serves two needs two providers.
|
||||
Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in `settings.yaml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers.
|
||||
|
||||

|
||||

|
||||
|
||||
The Provider ID is permanent because requests, saved sessions, model defaults, and credential references use it. To rename a provider, add a new provider and delete the old one. The display name, base URL, protocol, credential, and models remain editable.
|
||||
|
||||
|
||||
@@ -8,39 +8,39 @@
|
||||
|
||||
打开**设置 → 模型**。DeepSeek 卡片提供一个 API 密钥字段;输入密钥并保存。
|
||||
|
||||

|
||||

|
||||
|
||||
密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。
|
||||
|
||||
## 添加内置提供方
|
||||
## 添加第三方提供商
|
||||
|
||||
选择**添加提供方**,选取 dsh 自带的提供方;列表显示的是提供方 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`。输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。
|
||||
选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。
|
||||
|
||||
通过 OAuth 登录的提供方(例如 Codex)暂不支持。
|
||||
通过 OAuth 登录的提供商(例如 Codex)暂不支持。
|
||||
|
||||
## 添加自定义提供方
|
||||
## 添加自定义模型 API
|
||||
|
||||
对于公司网关、自建服务器或已安装目录中不存在的提供方,选择**添加自定义提供方**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,表单提供三种:`openai-completions` 对应 OpenAI Chat Completions,`openai-responses` 对应 OpenAI Responses API,`anthropic-messages` 对应 Anthropic Messages API。一个提供方只使用一种协议,网关同时提供两种时需要建两个提供方。
|
||||
对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在 `settings.yaml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。
|
||||
|
||||

|
||||

|
||||
|
||||
Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供方,请添加新提供方并删除旧提供方。显示名称、基础 URL、协议、凭据和模型仍可编辑。
|
||||
Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供商,请添加新提供商并删除旧提供商。显示名称、基础 URL、协议、凭据和模型仍可编辑。
|
||||
|
||||
### 探测模型
|
||||
|
||||
在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供方则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供方之前不会存储任何内容。
|
||||
在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供商之前不会存储任何内容。
|
||||
|
||||
探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供方一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供方探测。
|
||||
探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供商一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供商探测。
|
||||
|
||||
## 选择模型
|
||||
|
||||
已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。
|
||||
已配置的提供商会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。
|
||||
|
||||
如果已保存默认值指向已删除的提供方,输入框会显示**选择模型**,并在选择其他模型前阻止输入。
|
||||
如果已保存默认值指向已删除的提供商,输入框会显示**选择模型**,并在选择其他模型前阻止输入。
|
||||
|
||||
## 进阶配置
|
||||
|
||||
自动生成的[插件配置目录](../../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) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。
|
||||
自动生成的[插件配置目录](../../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) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。
|
||||
|
||||
::: tip 其他设置
|
||||
模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
|
||||
@@ -48,9 +48,9 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
|
||||
|
||||
### 图片输入
|
||||
|
||||
在**设置 → 模型**中编辑提供方,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。
|
||||
在**设置 → 模型**中编辑提供商,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。
|
||||
|
||||
复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供方声明了一个纯文本模型和一个视觉模型:
|
||||
复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供商声明了一个纯文本模型和一个视觉模型:
|
||||
|
||||
```yaml
|
||||
llm-pi-ai:
|
||||
@@ -86,7 +86,7 @@ llm-pi-ai:
|
||||
- id: second-model
|
||||
```
|
||||
|
||||
`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键:
|
||||
`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供商上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供商没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键:
|
||||
|
||||
```yaml
|
||||
llm-pi-ai:
|
||||
@@ -99,11 +99,11 @@ llm-pi-ai:
|
||||
|
||||
在 pi-ai 配置中,除模型自身的 `input` 外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。
|
||||
|
||||
这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。
|
||||
这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供商拒绝该请求。
|
||||
|
||||
### 推理等级
|
||||
|
||||
对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供方的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级:
|
||||
对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供商的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级:
|
||||
|
||||
```yaml
|
||||
llm-pi-ai:
|
||||
@@ -136,7 +136,7 @@ llm-pi-ai:
|
||||
max: max
|
||||
```
|
||||
|
||||
网关并不提供推理能力的内置提供方模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值:
|
||||
网关并不提供推理能力的内置提供商模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值:
|
||||
|
||||
```yaml
|
||||
llm-deepseek:
|
||||
@@ -181,8 +181,8 @@ llm-pi-ai:
|
||||
|
||||
## 排错
|
||||
|
||||
- **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。
|
||||
- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。
|
||||
- **`MISSING_CREDENTIAL`**:通过模型页存储提供商密钥,或提供被引用的环境变量。
|
||||
- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供商添加缺失的模型。
|
||||
- **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。
|
||||
- **获取可用模型提示既没有 `data` 数组也没有 `models` 对象**:端点返回的列表格式不在探测的读取范围内。请手动输入模型。
|
||||
- **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。
|
||||
@@ -190,5 +190,5 @@ llm-pi-ai:
|
||||
- **手动录入的模型没有推理等级菜单**:该模型没有声明任何等级。在 `settings.yaml` 中给该模型加上 `reasoningEfforts`。
|
||||
- **`off` 无法让 DeepSeek 模型停止思考**:留空的 `off` 不发送任何推理字段,默认思考的端点就继续思考。请在模型或路由上设置 `compat.thinkingFormat: deepseek`。
|
||||
- **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。
|
||||
- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。
|
||||
- **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。
|
||||
- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供商的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。
|
||||
- **提供商拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。
|
||||
@@ -125,6 +125,12 @@
|
||||
- id: cordis-host-runner
|
||||
name: '@deepseek-ai/dsh-cordis-host-runner'
|
||||
|
||||
# The Host inspect providers are process-global: the registry rejects a
|
||||
# duplicate id, so they register here once and every preset's `tool-cordis`
|
||||
# row reads them.
|
||||
- id: cordis-inspect-providers
|
||||
name: '@deepseek-ai/dsh-tool-cordis/host'
|
||||
|
||||
# Ordinary provider for the parsed Web flags. Its plugin-level injection
|
||||
# waits for cmdlineArgs; no launcher metadata or special row kind is needed.
|
||||
- id: web-startup
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-turn-outline": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-frontend": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 6ba9e8f79b9b8f8473fec2094a724e9d371e0f4e
|
||||
README.zh.md: 919659266ca729dfdaf50b00783cff065112ee07
|
||||
README.md: 292f1cc817d30837e36ced9adeee3d226852f4a5
|
||||
README.zh.md: c61ecb0cba1237d8fbf0e4d22333f5e21724c9fa
|
||||
@@ -38,6 +38,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
|---|---|
|
||||
| `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. |
|
||||
| `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. |
|
||||
| `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`<id>-<value>`) and the panel it controls (`<id>-<value>-panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. |
|
||||
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
|
||||
| `Input` | Single-line text entry for search boxes and inline forms. |
|
||||
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
|
||||
@@ -62,9 +63,10 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. |
|
||||
| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. |
|
||||
|
||||
Three pairs are easy to confuse:
|
||||
Four pairs are easy to confuse:
|
||||
|
||||
- **`Tag` against `Pill`.** Reach for `Tag` for a read-only badge at the 11px capsule size, and for `Pill` when the capsule is selectable (`active` and `onClick`, as view switchers and filters use) or when it must sit on a 24px text line — `TerminalBlock` renders its exit status as a static `Pill` for exactly that reason. Size decides as much as interactivity here; the two are not interchangeable.
|
||||
- **`Pill` against `SegmentedControl`.** A row of `Pill`s is a set of independent chips — each one toggles on its own, and several may be active. `SegmentedControl` is one choice among a few mutually exclusive modes, drawn as a tablist with one indicator, and it comes with the tab keyboard pattern (arrow keys walk the segments, only the selected one is in the tab sequence); the Models settings add card switches its two forms with it.
|
||||
- **`DisclosureRow` against a card.** The row lays its title and content side by side at a fixed 24px. A card that stacks a name over a description is a different layout, and belongs in the feature package — `ui-settings-plugins`' `PluginCard` is the precedent and records why.
|
||||
- **`FoldToggle` against the exported surface.** It is package-internal and not exported; the output cards use it for their head-tail fold.
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ kind: "package-library"
|
||||
|---|---|
|
||||
| `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 |
|
||||
| `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 |
|
||||
| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`<id>-<value>`)及其控制的面板 id(`<id>-<value>-panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 |
|
||||
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
|
||||
| `Input` | 单行文本输入,用于搜索框与行内表单。 |
|
||||
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
||||
@@ -62,9 +63,10 @@ kind: "package-library"
|
||||
| `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 |
|
||||
| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 |
|
||||
|
||||
有三组容易混淆:
|
||||
有四组容易混淆:
|
||||
|
||||
- **`Tag` 与 `Pill`。** 11px 胶囊尺寸的只读徽章用 `Tag`;胶囊可选中(`active` 与 `onClick`,视图切换与筛选器就是这样用的),或者必须落在 24px 文本行上时用 `Pill`——`TerminalBlock` 把退出状态渲染成静态 `Pill` 正是后一种情况。这里尺寸和是否可交互同样是判据,两者不可互换。
|
||||
- **`Pill` 与 `SegmentedControl`。** 一排 `Pill` 是一组彼此独立的 chip——每个各自开关,可以同时激活多个。`SegmentedControl` 是在几种互斥模式中选一,画成带一个指示块的 tablist,并自带 tab 键盘模式(方向键在分段间移动,只有选中项在 Tab 序列里);模型设置页的新增卡片就用它切换两张表单。
|
||||
- **`DisclosureRow` 与卡片。** 该行以固定 24px 把标题与内容左右排列。把名称叠在描述之上的卡片是另一种布局,属于功能包——`ui-settings-plugins` 的 `PluginCard` 是先例,并记录了原因。
|
||||
- **`FoldToggle` 与对外导出面。** 它是包内组件,未导出;输出卡片用它做头尾折叠。
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/* The track is the same translucent fill the hover state uses, so it reads as
|
||||
* a place rather than a second button; the one raised part is the indicator,
|
||||
* a white pill that slides under the selected segment. Its stroke rides inside
|
||||
* the soft elevation shadow (border: 0), the way every elevated surface draws
|
||||
* its edge, so the elevation gate keeps it off the alias-border set.
|
||||
*
|
||||
* Segments are equal-width grid tracks sized to the widest label, which is
|
||||
* what lets one indicator be placed arithmetically: the tablist carries the
|
||||
* segment count and the selected index as custom properties, and the indicator
|
||||
* derives its width and offset from them instead of measuring the DOM. */
|
||||
.control {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 9px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: calc((100% - 6px - 2px * (var(--dsh-segment-count) - 1)) / var(--dsh-segment-count));
|
||||
height: calc(100% - 6px);
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
box-shadow: var(--dsw-elevation-soft);
|
||||
transform: translateX(calc(var(--dsh-segment-index) * (100% + 2px)));
|
||||
transition: transform 160ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 28px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.tab:hover:not(:disabled),
|
||||
.tab[aria-selected='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tab:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indicator,
|
||||
.tab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// SegmentedControl: a tablist of two or more equal-width segments with one
|
||||
// sliding indicator, for switching a card or panel between a few modes. The
|
||||
// owner holds the selection; `label` is required so the tablist never ships
|
||||
// without an accessible name, and every segment label is owner-localized.
|
||||
// Each tab is `<id>-<value>` and controls the panel `<id>-<value>-panel`, the
|
||||
// element the owner renders for that mode and points back at the tab with
|
||||
// `aria-labelledby`.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { CSSProperties, KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import css from './SegmentedControl.module.css'
|
||||
|
||||
/** One segment of a {@link SegmentedControl}. */
|
||||
export interface SegmentedControlOption<Value extends string> {
|
||||
/** The value the owner receives when this segment is chosen. */
|
||||
value: Value
|
||||
/** Localized segment text. */
|
||||
label: string
|
||||
/** Whether the segment refuses selection. */
|
||||
disabled?: boolean
|
||||
/** Localized hover text, typically why the segment is locked. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** The keys that walk the tablist, per the WAI-ARIA tabs pattern. */
|
||||
type WalkKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown' | 'Home' | 'End'
|
||||
|
||||
function isWalkKey(key: string): key is WalkKey {
|
||||
return key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown'
|
||||
|| key === 'Home' || key === 'End'
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled option a walk key lands on from the selected one: arrows step
|
||||
* to the nearest enabled neighbour and wrap, Home and End jump to the first
|
||||
* and last enabled option.
|
||||
*/
|
||||
function walk<Value extends string>(
|
||||
options: readonly SegmentedControlOption<Value>[],
|
||||
from: number,
|
||||
key: WalkKey,
|
||||
): SegmentedControlOption<Value> | undefined {
|
||||
const enabled = options.filter(option => option.disabled !== true)
|
||||
if (key === 'Home') return enabled[0]
|
||||
if (key === 'End') return enabled[enabled.length - 1]
|
||||
const step = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1
|
||||
const count = options.length
|
||||
for (let offset = 1; offset < count; offset += 1) {
|
||||
const candidate = options[((from + step * offset) % count + count) % count]
|
||||
if (candidate !== undefined && candidate.disabled !== true) return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a segmented control.
|
||||
* @param props.id - the owner's base id: each tab is `<id>-<value>` and names
|
||||
* `<id>-<value>-panel` as the panel it controls.
|
||||
* @param props.value - the selected option's value; the control is fully controlled.
|
||||
* @param props.options - the segments in display order; at least two.
|
||||
* @param props.onChange - called with the value a click or a walk key asks for,
|
||||
* never with the value already selected.
|
||||
* @param props.label - localized accessible name of the tablist.
|
||||
* @param props.disabled - lock every segment, typically while the shown panel
|
||||
* has a write or a fetch in flight that switching would orphan.
|
||||
* @param props.className - extra class for layout placement.
|
||||
* @returns the tablist element.
|
||||
*/
|
||||
export function SegmentedControl<Value extends string>({
|
||||
id, value, options, onChange, label, disabled = false, className,
|
||||
}: {
|
||||
id: string
|
||||
value: Value
|
||||
options: readonly SegmentedControlOption<Value>[]
|
||||
onChange: (next: Value) => void
|
||||
label: string
|
||||
disabled?: boolean
|
||||
// `| undefined` so a caller can forward an optional class straight through
|
||||
// under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
|
||||
className?: string | undefined
|
||||
}) {
|
||||
const list = useRef<HTMLDivElement>(null)
|
||||
const selected = options.findIndex(option => option.value === value)
|
||||
|
||||
// A walk key reports the next value and the owner re-renders with it; the
|
||||
// keyboard then follows the selection so the roving tabindex stays usable.
|
||||
useEffect(() => {
|
||||
const root = list.current
|
||||
/* v8 ignore next -- the ref is attached to the always-rendered root before any effect runs. */
|
||||
if (root === null) return
|
||||
if (!root.contains(document.activeElement)) return
|
||||
root.querySelector<HTMLButtonElement>('[role="tab"][aria-selected="true"]')?.focus()
|
||||
}, [value])
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (!isWalkKey(event.key)) return
|
||||
event.preventDefault()
|
||||
const target = walk(options, selected, event.key)
|
||||
if (target !== undefined && target.value !== value) onChange(target.value)
|
||||
}
|
||||
|
||||
const indicator = {
|
||||
'--dsh-segment-count': String(options.length),
|
||||
'--dsh-segment-index': String(selected),
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={list}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className={clsx(css.control, className)}
|
||||
style={indicator}
|
||||
>
|
||||
<span aria-hidden="true" className={css.indicator} />
|
||||
{options.map((option) => {
|
||||
const active = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
id={`${id}-${option.value}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={`${id}-${option.value}-panel`}
|
||||
tabIndex={active ? 0 : -1}
|
||||
disabled={disabled || option.disabled === true}
|
||||
title={option.title}
|
||||
className={css.tab}
|
||||
onClick={() => { if (!active) onChange(option.value) }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export type { SegmentedTab } from './SegmentedTabs.tsx'
|
||||
export { Tag } from './Tag.tsx'
|
||||
export type { TagTone } from './Tag.tsx'
|
||||
export { Switch } from './Switch.tsx'
|
||||
export { SegmentedControl } from './SegmentedControl.tsx'
|
||||
export type { SegmentedControlOption } from './SegmentedControl.tsx'
|
||||
export { Checkbox } from './Checkbox.tsx'
|
||||
export { Input } from './Input.tsx'
|
||||
export { Menu, MenuItemButton } from './Menu.tsx'
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The segmented control is a tablist whose selection the owner holds. */
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
type Mode = 'catalog' | 'custom' | 'import'
|
||||
|
||||
const OPTIONS: ReadonlyArray<{ value: Mode; label: string; disabled?: boolean; title?: string }> = [
|
||||
{ value: 'catalog', label: 'Catalog' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
{ value: 'import', label: 'Import' },
|
||||
]
|
||||
|
||||
function mount(value: Mode, extra: {
|
||||
options?: typeof OPTIONS
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
} = {}) {
|
||||
const onChange = vi.fn<(next: Mode) => void>()
|
||||
const view = render(
|
||||
<SegmentedControl
|
||||
id="add"
|
||||
label="Add mode"
|
||||
value={value}
|
||||
options={extra.options ?? OPTIONS}
|
||||
onChange={onChange}
|
||||
{...extra.className === undefined ? {} : { className: extra.className }}
|
||||
{...extra.disabled === undefined ? {} : { disabled: extra.disabled }}
|
||||
/>,
|
||||
)
|
||||
return { view, onChange }
|
||||
}
|
||||
|
||||
function tab(name: string): HTMLButtonElement {
|
||||
return screen.getByRole<HTMLButtonElement>('tab', { name })
|
||||
}
|
||||
|
||||
describe('SegmentedControl', () => {
|
||||
it('exposes a named tablist with one tab per option and the owner-held selection', () => {
|
||||
mount('custom')
|
||||
const list = screen.getByRole('tablist', { name: 'Add mode' })
|
||||
expect(list.querySelectorAll('[role="tab"]')).toHaveLength(3)
|
||||
expect(tab('Catalog').getAttribute('aria-selected')).toBe('false')
|
||||
expect(tab('Custom').getAttribute('aria-selected')).toBe('true')
|
||||
expect(tab('Import').getAttribute('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('reports the clicked option and stays silent on the one already selected', () => {
|
||||
const { onChange } = mount('catalog')
|
||||
fireEvent.click(tab('Custom'))
|
||||
expect(onChange).toHaveBeenCalledWith('custom')
|
||||
onChange.mockClear()
|
||||
fireEvent.click(tab('Catalog'))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a disabled option and carries its lock reason as the hover title', () => {
|
||||
const { onChange } = mount('catalog', {
|
||||
options: [OPTIONS[0]!, { value: 'custom', label: 'Custom', disabled: true, title: 'No protocol is mounted' }],
|
||||
})
|
||||
const locked = tab('Custom')
|
||||
expect(locked.disabled).toBe(true)
|
||||
expect(locked.getAttribute('title')).toBe('No protocol is mounted')
|
||||
fireEvent.click(locked)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps only the selected tab in the tab sequence', () => {
|
||||
mount('custom')
|
||||
expect(tab('Catalog').tabIndex).toBe(-1)
|
||||
expect(tab('Custom').tabIndex).toBe(0)
|
||||
expect(tab('Import').tabIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('moves the selection with the arrow keys, wrapping at either end', () => {
|
||||
const { onChange } = mount('import')
|
||||
fireEvent.keyDown(tab('Import'), { key: 'ArrowRight' })
|
||||
expect(onChange).toHaveBeenLastCalledWith('catalog')
|
||||
fireEvent.keyDown(tab('Import'), { key: 'ArrowLeft' })
|
||||
expect(onChange).toHaveBeenLastCalledWith('custom')
|
||||
})
|
||||
|
||||
it('jumps to the first and last enabled options with Home and End, skipping disabled ones', () => {
|
||||
const { onChange } = mount('custom', {
|
||||
options: [
|
||||
{ value: 'catalog', label: 'Catalog', disabled: true },
|
||||
OPTIONS[1]!,
|
||||
{ value: 'import', label: 'Import', disabled: true },
|
||||
],
|
||||
})
|
||||
fireEvent.keyDown(tab('Custom'), { key: 'Home' })
|
||||
fireEvent.keyDown(tab('Custom'), { key: 'End' })
|
||||
fireEvent.keyDown(tab('Custom'), { key: 'ArrowRight' })
|
||||
// Every enabled neighbour is the selected one itself, so nothing changes.
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lands Home and End on the first and last enabled options, not the first and last options', () => {
|
||||
const { onChange } = mount('import', {
|
||||
options: [{ value: 'catalog', label: 'Catalog', disabled: true }, OPTIONS[1]!, OPTIONS[2]!],
|
||||
})
|
||||
fireEvent.keyDown(tab('Import'), { key: 'Home' })
|
||||
expect(onChange).toHaveBeenLastCalledWith('custom')
|
||||
onChange.mockClear()
|
||||
fireEvent.keyDown(tab('Import'), { key: 'End' })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips disabled options while walking with the arrow keys', () => {
|
||||
const { onChange } = mount('catalog', {
|
||||
options: [OPTIONS[0]!, { value: 'custom', label: 'Custom', disabled: true }, OPTIONS[2]!],
|
||||
})
|
||||
fireEvent.keyDown(tab('Catalog'), { key: 'ArrowRight' })
|
||||
expect(onChange).toHaveBeenLastCalledWith('import')
|
||||
fireEvent.keyDown(tab('Catalog'), { key: 'End' })
|
||||
expect(onChange).toHaveBeenLastCalledWith('import')
|
||||
})
|
||||
|
||||
it('moves keyboard focus onto the newly selected tab once the owner adopts it', () => {
|
||||
const { view, onChange } = mount('catalog')
|
||||
tab('Catalog').focus()
|
||||
fireEvent.keyDown(tab('Catalog'), { key: 'ArrowRight' })
|
||||
view.rerender(
|
||||
<SegmentedControl id="add" label="Add mode" value="custom" options={OPTIONS} onChange={onChange} />,
|
||||
)
|
||||
expect(document.activeElement).toBe(tab('Custom'))
|
||||
})
|
||||
|
||||
it('leaves unrelated keys alone', () => {
|
||||
const { onChange } = mount('catalog')
|
||||
fireEvent.keyDown(tab('Catalog'), { key: 'Enter' })
|
||||
fireEvent.keyDown(tab('Catalog'), { key: 'a' })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('places the sliding indicator by selected index and option count', () => {
|
||||
mount('import')
|
||||
const list = screen.getByRole('tablist')
|
||||
expect(list.style.getPropertyValue('--dsh-segment-count')).toBe('3')
|
||||
expect(list.style.getPropertyValue('--dsh-segment-index')).toBe('2')
|
||||
})
|
||||
|
||||
it('keeps a caller class alongside its own so a render site can place it', () => {
|
||||
mount('catalog', { className: 'placed' })
|
||||
const list = screen.getByRole('tablist')
|
||||
expect(list.classList.contains('placed')).toBe(true)
|
||||
expect(list.classList.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('names each tab and the panel it controls from the owner id, so panels can point back', () => {
|
||||
mount('catalog')
|
||||
const catalog = tab('Catalog')
|
||||
expect(catalog.id).toBe('add-catalog')
|
||||
expect(catalog.getAttribute('aria-controls')).toBe('add-catalog-panel')
|
||||
expect(tab('Import').getAttribute('aria-controls')).toBe('add-import-panel')
|
||||
})
|
||||
|
||||
it('locks every segment while the owner reports the control disabled', () => {
|
||||
// A disabled button is unfocusable, so the walk keys cannot originate on
|
||||
// one; the click path is what a pointer can still reach.
|
||||
const { onChange } = mount('catalog', { disabled: true })
|
||||
for (const name of ['Catalog', 'Custom', 'Import']) expect(tab(name).disabled).toBe(true)
|
||||
fireEvent.click(tab('Custom'))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never submits a surrounding form', () => {
|
||||
const onSubmit = vi.fn((event: { preventDefault: () => void }) => { event.preventDefault() })
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<SegmentedControl id="add" label="Add mode" value="catalog" options={OPTIONS} onChange={() => {}} />
|
||||
</form>,
|
||||
)
|
||||
fireEvent.click(tab('Custom'))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
|
||||
README.md: a47f9a6dfa1faa9257c9b726140838e4858d33bb
|
||||
README.zh.md: 925f8e976559ef7e975fcd164406f8c42f0b0098
|
||||
README.md: fa9be2ef8d7b08e064c3ee5e7517e59013b7f5e1
|
||||
README.zh.md: 2974524fba91642aebd31cd155a6495989cb3eaf
|
||||
@@ -43,7 +43,7 @@ Expand **Customized settings → Model options** to edit each model. Both provid
|
||||
|
||||
### Adding and deleting providers
|
||||
|
||||
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. 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 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.
|
||||
**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 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.
|
||||
|
||||
### First-run dialogs
|
||||
|
||||
@@ -110,7 +110,8 @@ These limits define the editor's field coverage and the page's reach; they are c
|
||||
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved.
|
||||
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
|
||||
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
|
||||
- **Only pi-ai routes can be hand-declared** — the custom-API form writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
|
||||
- **The catalog select lists route identifiers** — `moonshotai`, `zai`, and the other pi-ai catalog ids appear as they are, with no product names, aliases, or search. A searchable picker with the custom form as one pinned entry would let the mode switch go; it needs the directory to carry display names first.
|
||||
- **Interrogation covers OpenAI-compatible and Anthropic Messages endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic uses its native model-listing route; every other protocol reports that it cannot be asked and its models are entered by hand.
|
||||
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "dsh Web 客户端的模型设置与产品引导插件:提供方行、API 密钥管理、模型列表与 DeepSeek 首次运行弹窗。"
|
||||
description: "dsh Web 客户端的模型设置与产品引导插件:提供商行、API 密钥管理、模型列表与 DeepSeek 首次运行弹窗。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供方的模型列表,并手工声明自定义 pi-ai 路由;页面以提供方行展示,一次只展开一张编辑卡片。该页面把提供方目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。
|
||||
`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,33 +25,33 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。
|
||||
从设置导航打开 Models 页面,即可看到每个已配置的提供商都有一行。其配置键未在任何位置配置的整分节提供商会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。
|
||||
|
||||
存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。
|
||||
存在已存储目录错误的提供商仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。
|
||||
|
||||
### API 密钥
|
||||
|
||||
编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。
|
||||
编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。
|
||||
|
||||
### 编辑提供方
|
||||
### 编辑提供商
|
||||
|
||||
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。
|
||||
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。
|
||||
|
||||
`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。
|
||||
|
||||
展开**自定义设置 → 模型选项**编辑模型。两个提供方家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供方默认值,最后回退为文本。已知 pi-ai 提供方会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。
|
||||
展开**自定义设置 → 模型选项**编辑模型。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。
|
||||
|
||||
### 新增与删除提供方
|
||||
### 新增与删除提供商
|
||||
|
||||
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点占位示例随所选协议变化: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、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
|
||||
**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 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、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
|
||||
|
||||
### 首次运行弹窗
|
||||
|
||||
版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供方都会直接结束该步骤、不做渲染;只有没有任何提供方的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。
|
||||
版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供商都会直接结束该步骤、不做渲染;只有没有任何提供商的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。
|
||||
|
||||
### 扩展 slot
|
||||
|
||||
本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。
|
||||
本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供商」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。
|
||||
|
||||
-----
|
||||
|
||||
@@ -61,7 +61,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
页面只持有脱敏后的描述符,从不持有完整设置分区:因此每次编辑都以 `settings.mutate` 路径操作落到已存分区上——每个改动字段一次 set、每个清除字段一次 unset、删除提供方行则一次 unset。
|
||||
页面只持有脱敏后的描述符,从不持有完整设置分区:因此每次编辑都以 `settings.mutate` 路径操作落到已存分区上——每个改动字段一次 set、每个清除字段一次 unset、删除提供商行则一次 unset。
|
||||
|
||||
### 校验
|
||||
|
||||
@@ -73,7 +73,7 @@ kind: "package-reference"
|
||||
|
||||
### 引导协调器
|
||||
|
||||
声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
|
||||
声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供商设置。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -87,7 +87,7 @@ kind: "package-reference"
|
||||
- [ui-settings](../ui-settings/README.zh.md)——本页所依赖 scope 与 schema 服务所在的领域底座。
|
||||
- [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。
|
||||
- [credentials](../../credentials/README.zh.md)——本页写入密钥所经的凭据引用 seam。
|
||||
- [llm](../../llm/README.zh.md)——本页所配置提供方所在的适配器注册表。
|
||||
- [llm](../../llm/README.zh.md)——本页所配置提供商所在的适配器注册表。
|
||||
- [Web 配置平面](../../../.agents/notes/archived/architecture/2026-07-30-web-config-plane.md)——手写编辑器的设计依据。
|
||||
|
||||
-----
|
||||
@@ -99,7 +99,7 @@ kind: "package-reference"
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包既不组装也不发送提供商请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
@@ -110,7 +110,8 @@ kind: "package-reference"
|
||||
|
||||
- **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义模型 API 表单写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供商的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **目录选择框列出的是路由标识符**:`moonshotai`、`zai` 等 pi-ai catalog id 原样显示,没有产品名、别名或搜索。一个把自定义表单作为置顶项的可搜索选择器可以取代方式切换;前提是目录先携带显示名称。
|
||||
- **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
* gateway, a self-hosted server, or a provider newer than the installed
|
||||
* catalog.
|
||||
*
|
||||
* This is a create, not an edit, which is why it is its own card rather than
|
||||
* This is a create, not an edit, which is why it is its own form rather than
|
||||
* the provider editor with extra fields: the route id is being *chosen* here,
|
||||
* and the settings address does not exist until it is. One `settings.mutate`
|
||||
* sets the whole profile at `providers.<route>`; the key travels separately
|
||||
* through `credentials/set` under the reference the profile records, exactly as
|
||||
* an existing provider's key does.
|
||||
* and the settings address does not exist until it is. It renders as the
|
||||
* custom-API panel of the section's add card; the card's mode switch names it
|
||||
* when both modes are offered, and with the custom mode alone the card shows
|
||||
* this form directly. One `settings.mutate` sets the whole profile at
|
||||
* `providers.<route>`; the key travels separately through `credentials/set`
|
||||
* under the reference the profile records, exactly as an existing provider's
|
||||
* key does.
|
||||
*
|
||||
* The three fields a hand-declared route cannot default — endpoint, protocol,
|
||||
* and at least one model — are required here rather than at load, so the
|
||||
@@ -21,7 +24,7 @@
|
||||
* levels instead.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
@@ -30,6 +33,7 @@ import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import type { ModelDraft } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef } from './store.ts'
|
||||
import { protocolLabel } from './protocol-label.ts'
|
||||
import type { ModelsOperations } from './operations.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
@@ -76,6 +80,11 @@ export interface CustomProviderCardProps {
|
||||
readOnly: boolean
|
||||
/** Close the card; `changed` reports whether a provider was created. */
|
||||
onClose: (changed: boolean) => void
|
||||
/**
|
||||
* Called once per change with whether the create or the list's endpoint
|
||||
* interrogation is in flight, so the owner can hold its surface still.
|
||||
*/
|
||||
onBusyChange?: (busy: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +93,7 @@ export interface CustomProviderCardProps {
|
||||
* @returns the creation card.
|
||||
*/
|
||||
export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
const { taken, protocols, operations, t } = props
|
||||
const { taken, protocols, operations, t, onBusyChange } = props
|
||||
// The write is checked against the revision on which this draft was opened.
|
||||
const [openedAt] = useState(() => props.revision)
|
||||
const [route, setRoute] = useState('')
|
||||
@@ -94,6 +103,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
const [keyDraft, setKeyDraft] = useState('')
|
||||
const [models, setModels] = useState<readonly ModelDraft[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [listBusy, setListBusy] = useState(false)
|
||||
useEffect(() => { onBusyChange?.(busy || listBusy) }, [busy, listBusy, onBusyChange])
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
/**
|
||||
* The profile write landed. Only the key write can still be outstanding, so
|
||||
@@ -198,9 +209,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
|
||||
return (
|
||||
<div className={styles['editor']}>
|
||||
<div className={styles['editorHeader']}>
|
||||
<span className={styles['editorTitle']}>{t('customTitle')}</span>
|
||||
</div>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customRoute')}</span>
|
||||
<input
|
||||
@@ -255,7 +263,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setProtocol(event.target.value) }}
|
||||
>
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{protocolLabel(t, choice)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles['field']}>
|
||||
@@ -292,6 +300,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
operations={operations}
|
||||
t={t}
|
||||
disabled={profileDisabled}
|
||||
onBusyChange={setListBusy}
|
||||
/>
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
{/* Only the gates with something to say render; the route-id gate has its
|
||||
|
||||
@@ -90,6 +90,13 @@ export interface ModelListEditorProps {
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable every control (read-only deployment or a pending write). */
|
||||
disabled: boolean
|
||||
/**
|
||||
* Called once per change with whether an endpoint interrogation is in
|
||||
* flight. The owning card folds it into its own busy state so the surface
|
||||
* around the card — a mode switch, say — can refuse to move while the
|
||||
* answer, and the picker it opens, is still bound for this list.
|
||||
*/
|
||||
onBusyChange: (busy: boolean) => void
|
||||
}
|
||||
|
||||
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
|
||||
@@ -139,9 +146,10 @@ function adopt(candidate: LlmDiscoveredModel): ModelDraft {
|
||||
* @returns the model-list editor.
|
||||
*/
|
||||
export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
const { models, onChange, probe, operations, t, disabled } = props
|
||||
const { models, onChange, probe, operations, t, disabled, onBusyChange } = props
|
||||
const { catalogProvider } = props
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEffect(() => { onBusyChange(busy) }, [busy, onBusyChange])
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
const [inheritedCatalog, setInheritedCatalog] = useState<{
|
||||
provider: string
|
||||
|
||||
@@ -305,20 +305,16 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* The two ways to gain a provider, as equal siblings spanning the same width
|
||||
as the rows above. Wraps rather than shrinking below a legible label. */
|
||||
/* The one entry to the add card, spanning the same width as the rows above. */
|
||||
.addActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
/* Overrides the shared button base above: these two are not pills sitting in
|
||||
a footer but the last slot of the provider list, so they split the row
|
||||
evenly and repeat the row cards' corner. Dashed, like every other "nothing
|
||||
here yet" affordance on this page, to read as a place rather than a
|
||||
command. */
|
||||
/* Overrides the shared button base above: this is not a pill sitting in a
|
||||
footer but the last slot of the provider list, so it fills the row and
|
||||
repeats the row cards' corner. Dashed, like every other "nothing here yet"
|
||||
affordance on this page, to read as a place rather than a command. */
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
gap: 6px;
|
||||
@@ -327,6 +323,28 @@
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* The add card's mode switch with the one-line purpose of the chosen mode
|
||||
beneath it; the switch hugs its labels rather than spanning the card. */
|
||||
.addModes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* One panel per mode. Both stay mounted while the card is open; the hidden
|
||||
attribute must win over the panel's own display, or the inactive draft
|
||||
would show beneath the active one. */
|
||||
.addPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.addPanel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.addCard,
|
||||
.setupCard {
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -5,17 +5,26 @@
|
||||
* solid configured or missing dots. A whole-section provider without a
|
||||
* configured key renders as its open setup card instead of a row, but only in
|
||||
* the first-run posture — no provider on the page can serve requests yet — and
|
||||
* only until the user closes that card; the add flow is a card carrying the
|
||||
* dormant-provider select. Each card kind owns its own open state, so closing
|
||||
* one never discards a draft in another. Every mutation writes through the
|
||||
* wire, while a provider removal first requires confirmation; the page
|
||||
* re-renders from pushed invalidations or the post-apply reload.
|
||||
* only until the user closes that card. The add flow is one card behind one
|
||||
* button: a mode switch chooses between adopting a dormant directory provider
|
||||
* (the catalog select over the provider editor) and declaring a custom model
|
||||
* API (the create form). 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 and an unvisited mode costs
|
||||
* nothing; the switch holds still while either panel has a write or an
|
||||
* endpoint interrogation in flight, since a switch underneath one would
|
||||
* orphan the answer. Each card kind owns its own open state, so closing one
|
||||
* never discards a draft in another. Every
|
||||
* mutation writes through the wire, while a provider removal first requires
|
||||
* confirmation; the page re-renders from pushed invalidations or the
|
||||
* post-apply reload.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, IconPlusOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, IconPlusOutlineRegular, Modal, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: pulls this package's SlotMap merge (the two Models child slots).
|
||||
import type {} from './slot-contract.ts'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
@@ -43,6 +52,12 @@ export interface ModelsSectionInjected {
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* The two ways the add card gains a provider: adopt a directory row the
|
||||
* adapter already knows, or declare a route it does not.
|
||||
*/
|
||||
type AddMode = 'catalog' | 'custom'
|
||||
|
||||
/** The child slots this section declares and dispatches (see ./slot-contract.ts). */
|
||||
type ModelsChildSlots = 'settings.models.provider-card' | 'settings.models.footer'
|
||||
|
||||
@@ -78,6 +93,18 @@ interface EditorTarget extends ProviderIdentity {
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/** A dormant directory row the add card can adopt, with its registered namespace. */
|
||||
interface AddableRow {
|
||||
row: ProviderRow
|
||||
namespace: SettingsNamespaceView
|
||||
}
|
||||
|
||||
/** The catalog draft the add card shows: its editor target and the namespace that takes the write. */
|
||||
interface CatalogDraft {
|
||||
target: EditorTarget
|
||||
namespace: SettingsNamespaceView
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
interface ProviderEditorRenderProps extends Pick<
|
||||
ProviderEditorProps,
|
||||
@@ -205,12 +232,19 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
const { controller, operations, schema, t } = injected
|
||||
const state = injected.useSnapshot(snapshot => snapshot)
|
||||
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [addMode, setAddMode] = useState<AddMode>('catalog')
|
||||
/** The modes shown since the add card opened; each keeps its panel mounted. */
|
||||
const [visited, setVisited] = useState<ReadonlySet<AddMode>>(() => new Set())
|
||||
/** Whether each add panel has a write or an interrogation in flight. */
|
||||
const [catalogBusy, setCatalogBusy] = useState(false)
|
||||
const [customBusy, setCustomBusy] = useState(false)
|
||||
/** Base of the add card's tab and panel ids. */
|
||||
const addId = useId()
|
||||
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
||||
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
||||
const [declaring, setDeclaring] = useState(false)
|
||||
const [dismissedSetup, setDismissedSetup] = useState<ReadonlySet<string>>(() => new Set())
|
||||
|
||||
const announceSaved = (target: ProviderIdentity): void => {
|
||||
@@ -220,19 +254,31 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
void controller.load().then(() => { setSavedTarget(target) })
|
||||
}
|
||||
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
/**
|
||||
* Close the add card whole. The catalog target is forgotten with it, since
|
||||
* `editing` doubles as the row editor's target once the card is closed and a
|
||||
* refresh could otherwise open the row of a provider the draft never saved.
|
||||
* The busy flags reset here because a panel that closes itself on success
|
||||
* unmounts before it can report idle.
|
||||
*/
|
||||
const closeAdd = (): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
setAddOpen(false)
|
||||
setCatalogBusy(false)
|
||||
setCustomBusy(false)
|
||||
}
|
||||
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
closeAdd()
|
||||
if (changed) announceSaved(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a setup card, which owns none of the state above: the row-editor,
|
||||
* add, and declare cards each own one of those, so clearing them here would
|
||||
* discard a draft the user opened beside this card. Dismissal is this card's
|
||||
* own — the provider falls back to an ordinary row for the rest of the
|
||||
* session, and reopens through Edit.
|
||||
* Close a setup card, which owns none of the state above: the row-editor
|
||||
* and add cards each own one of those, so clearing them here would discard
|
||||
* a draft the user opened beside this card. Dismissal is this card's own —
|
||||
* the provider falls back to an ordinary row for the rest of the session,
|
||||
* and reopens through Edit.
|
||||
*/
|
||||
const closeSetup = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setDismissedSetup(previous => new Set([...previous, target.provider]))
|
||||
@@ -291,19 +337,47 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
const anyUsable = state.rows.some(providerUsable)
|
||||
const configured = state.rows.filter(row => row.configured)
|
||||
const configurable = state.rows.filter(row => state.namespaces.has(row.entry.settingsNs))
|
||||
const addable = configurable.filter(row => !row.configured)
|
||||
const addTarget = adding ? editing : undefined
|
||||
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
|
||||
const addable: AddableRow[] = state.rows.flatMap((row) => {
|
||||
const namespace = state.namespaces.get(row.entry.settingsNs)
|
||||
return namespace === undefined || row.configured ? [] : [{ row, namespace }]
|
||||
})
|
||||
// Hand-declared routes live in the pi-ai namespace, which is also the only
|
||||
// one whose schema names the protocols one may speak; without it mounted
|
||||
// there is nothing to declare and the mode is not offered.
|
||||
const piAi = state.namespaces.get('llm-pi-ai')
|
||||
const protocols = protocolChoices(piAi, schema)
|
||||
// Each mode is offered while its namespace is mounted and enabled while it
|
||||
// has something to offer; the card shows the chosen mode where both are
|
||||
// offered, else the only one there is. A mode's panel is mounted while it is
|
||||
// the shown mode or has been shown since the card opened — derived, so a
|
||||
// refresh that changes which modes are offered can never leave the card
|
||||
// without a panel.
|
||||
const catalogOffered = configurable.length > 0
|
||||
const catalogEnabled = addable.length > 0
|
||||
const customOffered = piAi !== undefined
|
||||
const customEnabled = protocols.length > 0
|
||||
const bothOffered = catalogOffered && customOffered
|
||||
const mode: AddMode = bothOffered ? addMode : customOffered ? 'custom' : 'catalog'
|
||||
const mounted = (candidate: AddMode): boolean => mode === candidate || visited.has(candidate)
|
||||
const switchLocked = catalogBusy || customBusy
|
||||
// The catalog draft: the row the user chose, kept through a refresh that
|
||||
// adopts or withdraws it elsewhere so a typed key is never discarded, for as
|
||||
// long as its namespace can still take the write; else the first row still
|
||||
// addable, since the mode can be entered by a refresh as well as by the
|
||||
// switch and the button only picks a target when it opens the card.
|
||||
const draft = ((): CatalogDraft | undefined => {
|
||||
if (!addOpen || !catalogOffered) return undefined
|
||||
const kept = editing === undefined ? undefined : state.namespaces.get(editing.settingsNs)
|
||||
if (editing !== undefined && kept !== undefined) return { target: editing, namespace: kept }
|
||||
const first = addable[0]
|
||||
return first === undefined ? undefined : { target: targetOf(first.row), namespace: first.namespace }
|
||||
})()
|
||||
// The draft's directory row, for the card extension seat. A refresh can drop
|
||||
// the row mid-draft (the route was adopted or withdrawn elsewhere); the
|
||||
// draft card stays while the seat simply has no row to dispatch.
|
||||
const addRow = addTarget === undefined
|
||||
const addRow = draft === undefined
|
||||
? undefined
|
||||
: state.rows.find(row => row.entry.provider === addTarget.provider)
|
||||
// Hand-declared routes live in the pi-ai namespace, which is also the only
|
||||
// one whose schema names the protocols one may speak; without it mounted
|
||||
// there is nothing to declare and the entry point stays disabled.
|
||||
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema)
|
||||
: state.rows.find(row => row.entry.provider === draft.target.provider)
|
||||
|
||||
return (
|
||||
<div className={styles['section']}>
|
||||
@@ -349,7 +423,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
const open = !addOpen && editing?.provider === row.entry.provider
|
||||
const credentialConfigured = row.credential?.configured === true
|
||||
const credentialMissing = !credentialConfigured
|
||||
&& row.apiKeyEnv !== undefined
|
||||
@@ -392,11 +466,10 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
aria-label={providerCopy(t('editProvider'), target)}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
// One card at a time: leaving `declaring` set would show
|
||||
// the create card beside this editor, and closing either
|
||||
// one discards the other's draft.
|
||||
setDeclaring(false)
|
||||
setAdding(false)
|
||||
// One card at a time: the add card closes with whatever
|
||||
// it held, since closing either card would otherwise
|
||||
// discard the other's draft.
|
||||
setAddOpen(false)
|
||||
setEditing(open ? undefined : target)
|
||||
}}
|
||||
>
|
||||
@@ -443,110 +516,150 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
})}
|
||||
</ul>
|
||||
<div className={styles['addBlock']}>
|
||||
{addTarget !== undefined && addNamespace !== undefined
|
||||
{addOpen
|
||||
? (
|
||||
<div className={styles['addCard']}>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('provider')}</span>
|
||||
<select
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={addTarget.provider}
|
||||
aria-label={t('provider')}
|
||||
onChange={(event) => {
|
||||
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
|
||||
/* v8 ignore next -- the select only lists addable rows */
|
||||
if (row === undefined) return
|
||||
setEditing(targetOf(row))
|
||||
}}
|
||||
>
|
||||
{addable.map(row => (
|
||||
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className={styles['addModes']}>
|
||||
{bothOffered
|
||||
? (
|
||||
<SegmentedControl
|
||||
id={addId}
|
||||
label={t('addMode')}
|
||||
value={mode}
|
||||
disabled={switchLocked}
|
||||
options={[
|
||||
{
|
||||
value: 'catalog',
|
||||
label: t('addCatalog'),
|
||||
disabled: !catalogEnabled,
|
||||
...catalogEnabled ? {} : { title: t('addCatalogExhausted') },
|
||||
},
|
||||
{
|
||||
value: 'custom',
|
||||
label: t('addCustom'),
|
||||
disabled: !customEnabled,
|
||||
...customEnabled ? {} : { title: t('addCustomUnavailable') },
|
||||
},
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setAddMode(next)
|
||||
setVisited(previous => new Set([...previous, next]))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
// One mode alone has no switch to name it, so the card
|
||||
// carries the mode as its title instead.
|
||||
<div className={styles['editorHeader']}>
|
||||
<span className={styles['editorTitle']}>{t(mode === 'catalog' ? 'addCatalog' : 'addCustom')}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className={styles['advancedHint']}>
|
||||
{t(mode === 'catalog' ? 'addCatalogHint' : 'addCustomHint')}
|
||||
</p>
|
||||
</div>
|
||||
<ProviderEditor
|
||||
key={addTarget.provider}
|
||||
provider={addTarget.provider}
|
||||
displayName={addTarget.displayName}
|
||||
hideTitle
|
||||
namespace={addNamespace}
|
||||
schema={schema}
|
||||
settingsPath={addTarget.settingsPath}
|
||||
operations={operations}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={(changed) => { closeEditor(changed, addTarget) }}
|
||||
/>
|
||||
{addRow === undefined
|
||||
? null
|
||||
: renderSlot(
|
||||
'settings.models.provider-card',
|
||||
{ provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) },
|
||||
{ entryKey: addRow.entry.settingsNs },
|
||||
)}
|
||||
{mounted('catalog') && draft !== undefined
|
||||
? (
|
||||
<div
|
||||
id={`${addId}-catalog-panel`}
|
||||
{...bothOffered ? { role: 'tabpanel', 'aria-labelledby': `${addId}-catalog` } : {}}
|
||||
hidden={mode !== 'catalog'}
|
||||
className={styles['addPanel']}
|
||||
>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('provider')}</span>
|
||||
<select
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={draft.target.provider}
|
||||
aria-label={t('provider')}
|
||||
disabled={catalogBusy}
|
||||
onChange={(event) => {
|
||||
const picked = addable.find(candidate => candidate.row.entry.provider === event.target.value)
|
||||
/* v8 ignore next -- the select only lists addable rows */
|
||||
if (picked === undefined) return
|
||||
setEditing(targetOf(picked.row))
|
||||
}}
|
||||
>
|
||||
{addable.map(({ row }) => (
|
||||
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<ProviderEditor
|
||||
key={draft.target.provider}
|
||||
provider={draft.target.provider}
|
||||
displayName={draft.target.displayName}
|
||||
hideTitle
|
||||
namespace={draft.namespace}
|
||||
schema={schema}
|
||||
settingsPath={draft.target.settingsPath}
|
||||
operations={operations}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={(changed) => { closeEditor(changed, draft.target) }}
|
||||
onBusyChange={setCatalogBusy}
|
||||
/>
|
||||
{addRow === undefined
|
||||
? null
|
||||
: renderSlot(
|
||||
'settings.models.provider-card',
|
||||
{ provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) },
|
||||
{ entryKey: addRow.entry.settingsNs },
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{mounted('custom') && piAi !== undefined
|
||||
? (
|
||||
<div
|
||||
id={`${addId}-custom-panel`}
|
||||
{...bothOffered ? { role: 'tabpanel', 'aria-labelledby': `${addId}-custom` } : {}}
|
||||
hidden={mode !== 'custom'}
|
||||
className={styles['addPanel']}
|
||||
>
|
||||
<CustomProviderCard
|
||||
taken={state.rows.map(row => row.entry.provider)}
|
||||
protocols={protocols}
|
||||
revision={piAi.revision}
|
||||
operations={operations}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={(changed) => {
|
||||
closeAdd()
|
||||
if (changed) void controller.load()
|
||||
}}
|
||||
onBusyChange={setCustomBusy}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: declaring
|
||||
: catalogOffered || customOffered
|
||||
? (
|
||||
<div className={styles['addCard']}>
|
||||
<CustomProviderCard
|
||||
taken={state.rows.map(row => row.entry.provider)}
|
||||
protocols={protocols}
|
||||
/* v8 ignore next -- the card only opens from a button disabled without this namespace */
|
||||
revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
|
||||
operations={operations}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={(changed) => {
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
// One entry for both ways to gain a provider; the card behind it
|
||||
// splits them. Full width, so it lines up with the rows above.
|
||||
<div className={styles['addActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={!state.writable || (!catalogEnabled && !customEnabled)}
|
||||
onClick={() => {
|
||||
const first = addable[0]
|
||||
const initial: AddMode = catalogEnabled ? 'catalog' : 'custom'
|
||||
setSavedTarget(undefined)
|
||||
setEditing(first === undefined ? undefined : targetOf(first.row))
|
||||
setAddMode(initial)
|
||||
setVisited(new Set([initial]))
|
||||
setAddOpen(true)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<IconPlusOutlineRegular size={14} />
|
||||
{t('add')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
// One row for the two ways to gain a provider: adopt one the
|
||||
// adapter already knows, or declare one it does not. Side by side
|
||||
// and equal-width so they read as siblings and line up with the
|
||||
// rows above, rather than two pills of different lengths.
|
||||
<div className={styles['addActions']}>
|
||||
{configurable.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={addable.length === 0 || !state.writable}
|
||||
onClick={() => {
|
||||
const first = addable[0]
|
||||
/* v8 ignore next -- the button is disabled while nothing is addable */
|
||||
if (first === undefined) return
|
||||
setSavedTarget(undefined)
|
||||
setDeclaring(false)
|
||||
setAdding(true)
|
||||
setEditing(targetOf(first))
|
||||
}}
|
||||
>
|
||||
<IconPlusOutlineRegular size={14} />
|
||||
{t('add')}
|
||||
</button>
|
||||
)}
|
||||
{state.namespaces.has('llm-pi-ai') && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={protocols.length === 0 || !state.writable}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setAdding(false)
|
||||
setEditing(undefined)
|
||||
setDeclaring(true)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutlineRegular size={14} />
|
||||
{t('customAdd')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
: null}
|
||||
</div>
|
||||
{renderSlot('settings.models.footer', {})}
|
||||
<Modal
|
||||
|
||||
@@ -34,6 +34,7 @@ import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef, protocolChoices } from './store.ts'
|
||||
import { protocolLabel } from './protocol-label.ts'
|
||||
import type { ModelsOperations } from './operations.ts'
|
||||
import type { SettingsSchemaOperations } from './schema-operations.ts'
|
||||
import type { en } from './locales.ts'
|
||||
@@ -86,6 +87,12 @@ export interface ProviderEditorProps {
|
||||
submitBusyLabelKey?: keyof typeof en
|
||||
/** Close the editor; `changed` reports whether an Apply committed. */
|
||||
onClose: (changed: boolean) => void
|
||||
/**
|
||||
* Called once per change with whether the apply or the model list's
|
||||
* endpoint interrogation is in flight, so the owner can hold its surface
|
||||
* still.
|
||||
*/
|
||||
onBusyChange?: (busy: boolean) => void
|
||||
}
|
||||
|
||||
/** A user-section subtree as a plain draft object (absent → empty). */
|
||||
@@ -160,6 +167,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const [keyDraft, setKeyDraft] = useState('')
|
||||
const [keyState, setKeyState] = useState<CredentialInfo | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [listBusy, setListBusy] = useState(false)
|
||||
const { onBusyChange } = props
|
||||
useEffect(() => { onBusyChange?.(busy || listBusy) }, [busy, listBusy, onBusyChange])
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
// A settings success advances both retry baselines immediately. Keeping the
|
||||
// derived fields in the draft prevents a pushed namespace refresh from
|
||||
@@ -445,7 +455,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
reader announces it either way, and an empty one is
|
||||
announced as a choice with no identity. */}
|
||||
{probeApi === undefined ? <option value="">{t('customApiUnset')}</option> : null}
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{protocolLabel(t, choice)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
@@ -471,6 +481,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
probe={probe}
|
||||
probeBlocked={keyFailure}
|
||||
operations={operations}
|
||||
onBusyChange={setListBusy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,14 @@ export const en = {
|
||||
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
|
||||
deleteConfirm: 'Delete {provider}',
|
||||
deleting: 'Deleting {provider}…',
|
||||
add: 'Add provider',
|
||||
add: 'Add model provider',
|
||||
addMode: 'How to add',
|
||||
addCatalog: 'Third-party model provider',
|
||||
addCustom: 'Custom model API',
|
||||
addCatalogHint: 'Pick OpenAI, Anthropic, Kimi, or another provider from the built-in catalog and enter its API key.',
|
||||
addCustomHint: 'Connect a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint by its base URL, protocol, and models.',
|
||||
addCatalogExhausted: 'Every catalog provider is already configured.',
|
||||
addCustomUnavailable: 'No API protocol is available to declare.',
|
||||
provider: 'Provider',
|
||||
close: 'Close',
|
||||
cancel: 'Cancel',
|
||||
@@ -78,8 +85,6 @@ export const en = {
|
||||
fetchSelectAll: 'Select all',
|
||||
fetchDeselectAll: 'Deselect all',
|
||||
fetchAdopt: 'Add selected',
|
||||
customAdd: 'Add a custom provider',
|
||||
customTitle: 'Custom provider',
|
||||
customTag: 'Custom',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.',
|
||||
@@ -88,6 +93,9 @@ export const en = {
|
||||
customDisplayName: 'Display name',
|
||||
customApi: 'API protocol',
|
||||
customApiUnset: 'Not selected',
|
||||
protocolOpenAiCompletions: 'OpenAI Chat Completions',
|
||||
protocolOpenAiResponses: 'OpenAI Responses',
|
||||
protocolAnthropicMessages: 'Anthropic Messages',
|
||||
customNeedsBaseUrl: 'A custom provider needs a base URL.',
|
||||
customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.',
|
||||
customNeedsModels: 'A custom provider needs at least one model.',
|
||||
@@ -115,7 +123,7 @@ export type ModelsKey = keyof typeof en
|
||||
export const zh: { [Key in keyof typeof en]: string } = {
|
||||
nav: '模型',
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
intro: '填入各提供商的 API 密钥即可使用其模型。',
|
||||
edit: '编辑',
|
||||
editProvider: '编辑 {provider}',
|
||||
remove: '删除',
|
||||
@@ -125,8 +133,15 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
|
||||
deleteConfirm: '删除 {provider}',
|
||||
deleting: '正在删除 {provider}…',
|
||||
add: '添加提供方',
|
||||
provider: '提供方',
|
||||
add: '添加模型提供商',
|
||||
addMode: '添加方式',
|
||||
addCatalog: '第三方模型提供商',
|
||||
addCustom: '自定义模型 API',
|
||||
addCatalogHint: '从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。',
|
||||
addCustomHint: '连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。',
|
||||
addCatalogExhausted: '目录中的提供商都已添加。',
|
||||
addCustomUnavailable: '没有可用的 API 协议。',
|
||||
provider: '提供商',
|
||||
close: '关闭',
|
||||
cancel: '取消',
|
||||
apply: '保存',
|
||||
@@ -135,7 +150,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
credentialConfigured: 'API 密钥已配置',
|
||||
credentialMissing: 'API 密钥缺失',
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
loadFailed: '加载提供商目录失败',
|
||||
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
|
||||
retry: '重试',
|
||||
keyInput: 'API 密钥',
|
||||
@@ -145,7 +160,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
keyEnvLocked: '由启动环境提供(只读)',
|
||||
customized: '自定义设置',
|
||||
baseUrl: 'API 地址',
|
||||
baseUrlDefault: '提供方默认',
|
||||
baseUrlDefault: '提供商默认',
|
||||
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic',
|
||||
deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。',
|
||||
models: '模型目录',
|
||||
@@ -157,9 +172,9 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
modelName: '显示名称',
|
||||
modelNamePlaceholder: '留空时使用模型 ID',
|
||||
contextWindow: '上下文窗口',
|
||||
contextWindowPlaceholder: '使用提供方默认值',
|
||||
contextWindowPlaceholder: '使用提供商默认值',
|
||||
maxTokens: '最大输出 token 数',
|
||||
maxTokensPlaceholder: '使用提供方默认值',
|
||||
maxTokensPlaceholder: '使用提供商默认值',
|
||||
modelAdvanced: '模型选项',
|
||||
modelInputTypes: '输入类型',
|
||||
modelInputText: '文本',
|
||||
@@ -168,7 +183,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
removeModel: '删除模型',
|
||||
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
|
||||
keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
|
||||
keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。',
|
||||
keyBlankNew: '请输入 API 密钥;若该提供商以其他方式鉴权,可以留空。',
|
||||
keyIllegalCharacters: '该 API 密钥格式错误,请检查。',
|
||||
modelIdRequired: '模型 ID 不能为空。',
|
||||
modelIdDuplicate: '模型 ID 不能重复。',
|
||||
@@ -179,33 +194,34 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。',
|
||||
modelDuplicate: '每个模型 ID 只能出现一次。',
|
||||
fetchModels: '获取可用模型',
|
||||
fetching: '正在询问提供方\u2026',
|
||||
fetching: '正在询问提供商\u2026',
|
||||
fetchNeedsBaseUrl: '请先填写 API 地址,再获取。',
|
||||
fetchEmpty: '该提供方没有列出任何模型,请手动添加。',
|
||||
fetchEmpty: '该提供商没有列出任何模型,请手动添加。',
|
||||
fetchTitle: '选择要添加的模型',
|
||||
fetchDescription: '以下是模型提供方的可用模型,勾选要添加的模型。',
|
||||
fetchDescription: '以下是模型提供商的可用模型,勾选要添加的模型。',
|
||||
fetchSearch: '搜索模型',
|
||||
fetchNoMatches: '没有匹配的模型。',
|
||||
fetchSelectAll: '全选',
|
||||
fetchDeselectAll: '取消全选',
|
||||
fetchAdopt: '添加所选',
|
||||
customAdd: '添加自定义提供方',
|
||||
customTitle: '自定义提供方',
|
||||
customTag: '自定义',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。',
|
||||
customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供商,并用于派生凭据名。',
|
||||
customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。',
|
||||
customRouteTaken: '已有提供方使用了这个 ID。',
|
||||
customRouteTaken: '已有提供商使用了这个 ID。',
|
||||
customDisplayName: '显示名称',
|
||||
customApi: 'API 协议',
|
||||
customApiUnset: '未选择',
|
||||
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
|
||||
protocolOpenAiCompletions: 'OpenAI Chat Completions',
|
||||
protocolOpenAiResponses: 'OpenAI Responses',
|
||||
protocolAnthropicMessages: 'Anthropic Messages',
|
||||
customNeedsBaseUrl: '自定义模型 API 需要填写 API 地址。',
|
||||
customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。',
|
||||
customNeedsModels: '自定义提供方至少需要一个模型。',
|
||||
customNeedsModels: '自定义模型 API 至少需要一个模型。',
|
||||
customBaseUrlPlaceholder: 'https://gateway.example/v1',
|
||||
customAnthropicBaseUrlPlaceholder: 'https://gateway.example',
|
||||
settingsPathUnresolvable: '无法解析设置路径',
|
||||
create: '创建提供方',
|
||||
create: '创建提供商',
|
||||
creating: '创建中\u2026',
|
||||
welcomeTitle: '内测声明',
|
||||
welcomeBody: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Product names for the wire protocols a pi-ai route may speak. The pickers
|
||||
* show these instead of the schema identifiers (`openai-completions`), which
|
||||
* are what `settings.yaml` carries and what the option values stay.
|
||||
*/
|
||||
|
||||
import type { ModelsKey } from './locales.ts'
|
||||
|
||||
/** The protocols this page names, keyed by their schema identifier. */
|
||||
const PROTOCOL_LABEL_KEYS: Readonly<Record<string, ModelsKey>> = {
|
||||
'openai-completions': 'protocolOpenAiCompletions',
|
||||
'openai-responses': 'protocolOpenAiResponses',
|
||||
'anthropic-messages': 'protocolAnthropicMessages',
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker text for one protocol identifier.
|
||||
* @param t - section copy.
|
||||
* @param protocol - the schema identifier of the protocol.
|
||||
* @returns the product name this page knows the protocol by; a protocol the
|
||||
* adapter adds before this page names it shows its identifier, the spelling
|
||||
* `settings.yaml` needs anyway.
|
||||
*/
|
||||
export function protocolLabel(t: (key: ModelsKey) => string, protocol: string): string {
|
||||
const key = PROTOCOL_LABEL_KEYS[protocol]
|
||||
return key === undefined ? protocol : t(key)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ function capacityInputs(label: string): HTMLInputElement[] {
|
||||
const PiAiConfig = Schema.object({
|
||||
providers: Schema.dict(Schema.object({
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
api: Schema.union(['openai-completions', 'openai-responses', 'anthropic-messages']),
|
||||
baseURL: Schema.string(),
|
||||
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
headers: Schema.dict(Schema.string()),
|
||||
@@ -307,12 +308,11 @@ async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] =
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
it('hides both add actions when their settings namespaces are absent', async () => {
|
||||
it('hides the add action when no settings namespace can open an editor', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: [] }))
|
||||
await mountFace(scripted)
|
||||
expect(screen.queryByRole('button', { name: en.add })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers only providers whose settings namespace can open an editor', async () => {
|
||||
@@ -322,8 +322,11 @@ describe('ModelsSection', () => {
|
||||
namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'),
|
||||
}))
|
||||
await mountFace(scripted)
|
||||
expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
// Without the pi-ai namespace nothing can be hand-declared, so the card
|
||||
// is the catalog form alone: no mode switch, no custom panel.
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
|
||||
expect(screen.queryByRole('option', { name: 'anthropic' })).toBeNull()
|
||||
expect(screen.getByRole('option', { name: 'plain' })).toBeTruthy()
|
||||
})
|
||||
@@ -339,7 +342,6 @@ describe('ModelsSection', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
expect(await screen.findByLabelText(en.keyInput)).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: en.add })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders nothing before the slot injects its dependencies', () => {
|
||||
@@ -1437,6 +1439,253 @@ describe('ModelsSection', () => {
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the add card on the catalog mode with both modes offered', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
const modes = screen.getByRole('tablist', { name: en.addMode })
|
||||
expect(within(modes).getByRole('tab', { name: en.addCatalog }).getAttribute('aria-selected')).toBe('true')
|
||||
expect(within(modes).getByRole('tab', { name: en.addCustom }).getAttribute('aria-selected')).toBe('false')
|
||||
expect(screen.getByText(en.addCatalogHint)).toBeTruthy()
|
||||
expect(screen.queryByText(en.addCustomHint)).toBeNull()
|
||||
expect(screen.getByRole('combobox', { name: en.provider })).toBeTruthy()
|
||||
// The custom panel is mounted but hidden, so its fields are not reachable.
|
||||
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
|
||||
})
|
||||
|
||||
it('switches between the modes without losing either draft', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-catalog' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
expect(screen.getByText(en.addCustomHint)).toBeTruthy()
|
||||
expect(screen.queryByText(en.addCatalogHint)).toBeNull()
|
||||
expect(screen.queryByRole('combobox', { name: en.provider })).toBeNull()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: en.customRoute }), { target: { value: 'acme' } })
|
||||
|
||||
// Both panels stay mounted from here on, so the shown one is queried by name.
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCatalog }))
|
||||
const catalog = screen.getByRole('tabpanel', { name: en.addCatalog })
|
||||
expect(within(catalog).getByLabelText<HTMLInputElement>(en.keyInput).value).toBe('sk-catalog')
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
expect(screen.getByRole<HTMLInputElement>('textbox', { name: en.customRoute }).value).toBe('acme')
|
||||
})
|
||||
|
||||
it('opens on the custom mode when every catalog provider is already configured', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
]))
|
||||
await mountFace(scripted)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
const catalog = screen.getByRole<HTMLButtonElement>('tab', { name: en.addCatalog })
|
||||
expect(catalog.disabled).toBe(true)
|
||||
expect(screen.getByRole('tab', { name: en.addCustom }).getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the custom form alone when no directory row can be adopted', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([]))
|
||||
await mountFace(scripted)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
expect(screen.queryByRole('combobox', { name: en.provider })).toBeNull()
|
||||
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables the add action when neither mode can proceed', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
]))
|
||||
// A pi-ai schema naming no protocol leaves nothing to declare either.
|
||||
const protocolless = JSON.parse(JSON.stringify(
|
||||
Schema.object({ providers: Schema.dict(Schema.object({})) }).toJSON(),
|
||||
)) as JsonValue
|
||||
scripted.face.settings.describe.mockResolvedValue(remoteOk({
|
||||
writable: true, hasDocument: false,
|
||||
namespaces: wireNamespaces().map(view => view.ns === 'llm-pi-ai' ? { ...view, schema: protocolless } : view),
|
||||
}))
|
||||
await mountFace(scripted)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.add }).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the custom panel when a refresh drops every configurable row mid-card', async () => {
|
||||
const { face, controller } = await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(screen.getByRole('combobox', { name: en.provider })).toBeTruthy()
|
||||
// The directory empties while the card is open: the only mode left is the
|
||||
// custom one, whose panel was never visited.
|
||||
face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([]))
|
||||
await act(async () => { await controller.load() })
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('picks a catalog target when the catalog becomes addable after the card opened', async () => {
|
||||
const scripted = scriptedFace()
|
||||
const exhausted = [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
]
|
||||
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk(exhausted))
|
||||
const { controller } = await mountFace(scripted)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(screen.getByRole<HTMLButtonElement>('tab', { name: en.addCatalog }).disabled).toBe(true)
|
||||
// A dormant route appears (another client deleted its profile, say).
|
||||
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
|
||||
...exhausted,
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'] },
|
||||
]))
|
||||
await act(async () => { await controller.load() })
|
||||
const catalog = screen.getByRole<HTMLButtonElement>('tab', { name: en.addCatalog })
|
||||
expect(catalog.disabled).toBe(false)
|
||||
fireEvent.click(catalog)
|
||||
expect(screen.getByRole<HTMLSelectElement>('combobox', { name: en.provider }).value).toBe('anthropic')
|
||||
expect(within(screen.getByRole('tabpanel', { name: en.addCatalog })).getByLabelText(en.keyInput)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('drops the custom panel when the pi-ai namespace disappears mid-card', async () => {
|
||||
const { face, controller, mirror } = await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
|
||||
face.settings.describe.mockResolvedValue(remoteOk({
|
||||
writable: true, hasDocument: false,
|
||||
namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'),
|
||||
}))
|
||||
// A namespace change reaches the page through the mirror's own refresh.
|
||||
await act(async () => {
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
})
|
||||
// Nothing can be declared any more, so the form is gone rather than left
|
||||
// to create a route the Host would refuse; the catalog form stands alone.
|
||||
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
expect(screen.getByRole('combobox', { name: en.provider })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('forgets the catalog target when the custom form closes, so a later refresh opens no row editor', async () => {
|
||||
const { face, controller, mirror } = await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(screen.getByRole<HTMLSelectElement>('combobox', { name: en.provider }).value).toBe('anthropic')
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
fireEvent.click(within(screen.getByRole('tabpanel', { name: en.addCustom })).getByText(en.cancel))
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
// The draft's provider gets configured elsewhere: its row must arrive closed.
|
||||
const withAnthropic = (layer: unknown): JsonValue => ({
|
||||
providers: { ...(layer as { providers: object }).providers, anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } },
|
||||
})
|
||||
face.settings.describe.mockResolvedValue(remoteOk({
|
||||
writable: true, hasDocument: false,
|
||||
namespaces: wireNamespaces().map(view => view.ns === 'llm-pi-ai'
|
||||
? { ...view, value: withAnthropic(view.value), user: withAnthropic(view.user) }
|
||||
: view),
|
||||
}))
|
||||
await act(async () => {
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: providerCopy(en.editProvider, { provider: 'anthropic', displayName: 'anthropic' }) })).toBeTruthy()
|
||||
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('locks the mode switch while the catalog form has a write in flight', async () => {
|
||||
let settle: (() => void) | undefined
|
||||
const pending = new Promise<void>((resolve) => { settle = resolve })
|
||||
const providerNamespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')!
|
||||
const mutate = vi.fn(() => pending.then(() => remoteOk(providerNamespace)))
|
||||
await mountSection({ mutate })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-slow' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(screen.getByRole<HTMLButtonElement>('tab', { name: en.addCustom }).disabled).toBe(true) })
|
||||
expect(screen.getByRole<HTMLButtonElement>('tab', { name: en.addCatalog }).disabled).toBe(true)
|
||||
await act(async () => { settle!(); await pending })
|
||||
// The apply closes the card once it lands; nothing was switched underneath it.
|
||||
await waitFor(() => { expect(screen.queryByRole('tablist')).toBeNull() })
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('locks the mode switch while the custom form is asking the endpoint for models', async () => {
|
||||
const scripted = scriptedFace()
|
||||
let settle: (() => void) | undefined
|
||||
const pending = new Promise<void>((resolve) => { settle = resolve })
|
||||
scripted.face.llm.discoverModels.mockImplementation(() => pending.then(() => remoteOk([])))
|
||||
await mountFace(scripted)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
const custom = within(screen.getByRole('tabpanel', { name: en.addCustom }))
|
||||
fireEvent.change(custom.getByRole('textbox', { name: en.baseUrl }), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(custom.getByRole('button', { name: en.fetchModels }))
|
||||
await waitFor(() => { expect(screen.getByRole<HTMLButtonElement>('tab', { name: en.addCatalog }).disabled).toBe(true) })
|
||||
await act(async () => { settle!(); await pending })
|
||||
await waitFor(() => { expect(screen.getByRole<HTMLButtonElement>('tab', { name: en.addCatalog }).disabled).toBe(false) })
|
||||
})
|
||||
|
||||
it('pairs each tab with its panel through ids', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
const catalogTab = screen.getByRole('tab', { name: en.addCatalog })
|
||||
const catalogPanel = screen.getByRole('tabpanel', { name: en.addCatalog })
|
||||
expect(catalogTab.getAttribute('aria-controls')).toBe(catalogPanel.id)
|
||||
expect(catalogPanel.getAttribute('aria-labelledby')).toBe(catalogTab.id)
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
const customTab = screen.getByRole('tab', { name: en.addCustom })
|
||||
const customPanel = screen.getByRole('tabpanel', { name: en.addCustom })
|
||||
expect(customTab.getAttribute('aria-controls')).toBe(customPanel.id)
|
||||
expect(customPanel.getAttribute('aria-labelledby')).toBe(customTab.id)
|
||||
})
|
||||
|
||||
it('titles a single-mode card with its mode instead of leaving an orphan tabpanel', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([]))
|
||||
await mountFace(scripted)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
expect(screen.queryByRole('tabpanel')).toBeNull()
|
||||
expect(screen.getByText(en.addCustom)).toBeTruthy()
|
||||
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('explains a locked mode through its hover title', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
]))
|
||||
await mountFace(scripted)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(screen.getByRole('tab', { name: en.addCatalog }).getAttribute('title')).toBe(en.addCatalogExhausted)
|
||||
expect(screen.getByRole('tab', { name: en.addCustom }).getAttribute('title')).toBeNull()
|
||||
})
|
||||
|
||||
it('explains a mode with no protocol to declare through its hover title', async () => {
|
||||
const scripted = scriptedFace()
|
||||
const protocolless = JSON.parse(JSON.stringify(
|
||||
Schema.object({ providers: Schema.dict(Schema.object({})) }).toJSON(),
|
||||
)) as JsonValue
|
||||
scripted.face.settings.describe.mockResolvedValue(remoteOk({
|
||||
writable: true, hasDocument: false,
|
||||
namespaces: wireNamespaces().map(view => view.ns === 'llm-pi-ai' ? { ...view, schema: protocolless } : view),
|
||||
}))
|
||||
await mountFace(scripted)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(screen.getByRole('tab', { name: en.addCustom }).getAttribute('title')).toBe(en.addCustomUnavailable)
|
||||
expect(screen.getByRole('tab', { name: en.addCatalog }).getAttribute('title')).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses the add card from the custom mode on cancel', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
fireEvent.click(within(screen.getByRole('tabpanel', { name: en.addCustom })).getByText(en.cancel))
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: en.add })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('cancels the add card back to the add button', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
|
||||
@@ -27,7 +27,7 @@ it('ignores a late catalog response after the provider changes', async () => {
|
||||
const onChange = vi.fn()
|
||||
const props = {
|
||||
models: [{ id: 'm' }], onChange, operations: actions,
|
||||
disabled: false, t: (key: keyof typeof en) => en[key],
|
||||
disabled: false, t: (key: keyof typeof en) => en[key], onBusyChange: () => {},
|
||||
}
|
||||
const { rerender } = render(<ModelListEditor {...props} catalogProvider="old" probe={{ settingsNs: 'llm-pi-ai', provider: 'old' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
|
||||
@@ -44,7 +44,7 @@ it('uses provider input defaults for a model absent from the installed catalog',
|
||||
const onChange = vi.fn()
|
||||
render(<ModelListEditor
|
||||
models={[{ id: 'custom' }]} onChange={onChange} defaultInput={['image']} catalogProvider="openai"
|
||||
probe={{ settingsNs: 'llm-pi-ai', provider: 'openai' }} disabled={false} t={key => en[key]}
|
||||
probe={{ settingsNs: 'llm-pi-ai', provider: 'openai' }} disabled={false} t={key => en[key]} onBusyChange={() => {}}
|
||||
operations={operations(() => Promise.resolve({ kind: 'found', models: [] }))}
|
||||
/>)
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
|
||||
@@ -60,7 +60,7 @@ it('inherits catalog inputs once an incomplete draft has a model id', async () =
|
||||
const props = {
|
||||
onChange, catalogProvider: 'openai',
|
||||
probe: { settingsNs: 'llm-pi-ai', provider: 'openai' },
|
||||
disabled: false, t: (key: keyof typeof en) => en[key],
|
||||
disabled: false, t: (key: keyof typeof en) => en[key], onBusyChange: () => {},
|
||||
operations: operations(() => Promise.resolve({
|
||||
kind: 'found', models: [{ id: 'vision', inputModalities: ['text', 'image'] }],
|
||||
})),
|
||||
@@ -89,7 +89,7 @@ it('restores inherited image input after a failed catalog read is retried manual
|
||||
const onChange = vi.fn()
|
||||
render(<ModelListEditor
|
||||
models={[{ id: 'vision' }]} onChange={onChange} catalogProvider="openai"
|
||||
probe={{ settingsNs: 'llm-pi-ai', provider: 'openai' }} disabled={false} t={key => en[key]}
|
||||
probe={{ settingsNs: 'llm-pi-ai', provider: 'openai' }} disabled={false} t={key => en[key]} onBusyChange={() => {}}
|
||||
operations={operations(discover)}
|
||||
/>)
|
||||
await screen.findByText('Catalog unavailable')
|
||||
|
||||
@@ -225,6 +225,12 @@ function openEditor(provider: string): void {
|
||||
fireEvent.click(summary)
|
||||
}
|
||||
|
||||
/** Open the add card from the section and switch it to the custom-API mode. */
|
||||
function openCustomMode(): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
}
|
||||
|
||||
/** Open one model row's advanced fold, where the capacities live. */
|
||||
function expandModel(index: number): void {
|
||||
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${index}`))
|
||||
@@ -1417,27 +1423,40 @@ describe('hand-declared providers', () => {
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes the create card when an existing row is opened for editing', async () => {
|
||||
it('closes the add card when an existing row is opened for editing', async () => {
|
||||
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
||||
expect(screen.getByText(en.customTitle)).toBeTruthy()
|
||||
openCustomMode()
|
||||
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
|
||||
|
||||
// Two cards at once would each be closable by the other: whichever one is
|
||||
// dismissed clears the shared state and discards the other's draft.
|
||||
openEditor('openai')
|
||||
expect(screen.queryByText(en.customTitle)).toBeNull()
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
|
||||
})
|
||||
|
||||
it('reaches the card from the section and returns to the button on cancel', async () => {
|
||||
it('reaches the custom form from the section and returns to the button on cancel', async () => {
|
||||
await mountSection()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
||||
expect(screen.getByText(en.customTitle)).toBeTruthy()
|
||||
openCustomMode()
|
||||
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
|
||||
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
|
||||
await waitFor(() => { expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() })
|
||||
expect(screen.getByRole('button', { name: en.add })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('names each protocol by its product name and falls back to the identifier of an unknown one', () => {
|
||||
mountCard({ protocols: [...PROTOCOLS, 'google-generative-ai'] })
|
||||
const protocol = screen.getByLabelText<HTMLSelectElement>(en.customApi)
|
||||
const labels = [...protocol.options].map(option => [option.value, option.textContent])
|
||||
expect(labels).toEqual([
|
||||
['openai-completions', en.protocolOpenAiCompletions],
|
||||
['openai-responses', en.protocolOpenAiResponses],
|
||||
['anthropic-messages', en.protocolAnthropicMessages],
|
||||
['google-generative-ai', 'google-generative-ai'],
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses an unusable key on the field and blocks creation', () => {
|
||||
@@ -1608,15 +1627,15 @@ describe('API key field', () => {
|
||||
const { controller, mutate } = await mountSection()
|
||||
const load = vi.spyOn(controller, 'load')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
openCustomMode()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: en.customRoute }), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: en.baseUrl }), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledOnce() })
|
||||
expect(screen.queryByText(en.customTitle)).toBeNull()
|
||||
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/tool-cordis/README.md
|
||||
README.md: 3536ae2fc39f7a8db425da89df3f4d93e1ca8b4f
|
||||
README.zh.md: 8127683111f1807b6920d6bb6f56479e1495f281
|
||||
README.md: 4e7efefd21e3b7cf40e8ba5fe2291d220dfd796f
|
||||
README.zh.md: 76331c25f0996c053b39e5b227a50afae84e6bb4
|
||||
@@ -25,7 +25,7 @@ Inspect Host and Client runtime APIs before writing plugin code. Creator mode pr
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-tool-cordis` alongside the host runner that provides `cordisInspect`. Call `cordis_inspect_list` to discover providers, then `cordis_inspect_query` for a provider's exact methods and types. Use [Plugin Manager](../../boot/plugin-manager/README.md) to install bundles containing plugin code or MCP configuration.
|
||||
Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-tool-cordis/host` once in the host composition beside the host runner that provides `cordisInspect`, and `@deepseek-ai/dsh-tool-cordis` in each agent preset that exposes the tools; a preset row alone registers no Host providers. Call `cordis_inspect_list` to discover providers, then `cordis_inspect_query` for a provider's exact methods and types. Use [Plugin Manager](../../boot/plugin-manager/README.md) to install bundles containing plugin code or MCP configuration.
|
||||
|
||||
-----
|
||||
|
||||
@@ -35,7 +35,7 @@ Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-t
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
Host providers combine generated Service/Event catalogs and the requesting agent's tool registry. Client providers synchronize their manifests through the existing inspection registry and answer queries from a connected page. The tool plugin owns its registrations through Cordis effects; disposal removes the two tools and the Host inspect providers. No invariant companion is published because inspection reads its providers directly and maintains no independent runtime projection.
|
||||
Host providers combine generated Service/Event catalogs and the requesting agent's tool registry. Client providers synchronize their manifests through the existing inspection registry and answer queries from a connected page. The host entry owns the Host provider registrations and the preset row owns the two tools, each through Cordis effects; the registry rejects a duplicate provider id, which is why the providers register once per process rather than per preset. No invariant companion is published because inspection reads its providers directly and maintains no independent runtime projection.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
创造模式包含这组工具。其他组合需要同时挂载 `@deepseek-ai/dsh-tool-cordis` 和提供 `cordisInspect` 的 host runner。调用 `cordis_inspect_list` 发现 provider,再用 `cordis_inspect_query` 查询其具体方法和类型。通过 [Plugin Manager](../../boot/plugin-manager/README.zh.md) 安装包含插件代码或 MCP 配置的组合包。
|
||||
创造模式包含这组工具。其他组合需要在宿主组合里、提供 `cordisInspect` 的 host runner 旁挂载一次 `@deepseek-ai/dsh-tool-cordis/host`,并在每个要暴露这些工具的 agent preset 里挂载 `@deepseek-ai/dsh-tool-cordis`;仅有 preset 行不会注册任何 Host provider。调用 `cordis_inspect_list` 发现 provider,再用 `cordis_inspect_query` 查询其具体方法和类型。通过 [Plugin Manager](../../boot/plugin-manager/README.zh.md) 安装包含插件代码或 MCP 配置的组合包。
|
||||
|
||||
-----
|
||||
|
||||
@@ -35,7 +35,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
Host provider 结合生成的 Service/Event 目录与请求 agent 的工具注册表。Client provider 通过现有检查注册表同步清单,并从已连接页面回答查询。工具插件通过 Cordis effect 持有注册;释放时移除两个工具和 Host inspect provider。检查直接读取 provider,不维护独立运行时投影,因此不发布不变式配套插件。
|
||||
Host provider 结合生成的 Service/Event 目录与请求 agent 的工具注册表。Client provider 通过现有检查注册表同步清单,并从已连接页面回答查询。宿主入口持有 Host provider 的注册,preset 行持有两个工具,都通过 Cordis effect;注册表拒绝重复的 provider id,所以 provider 按进程注册一次而不是按 preset 注册。检查直接读取 provider,不维护独立运行时投影,因此不发布不变式配套插件。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -18,11 +18,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./host": {
|
||||
"types": "./lib/types/host.d.ts",
|
||||
"default": "./lib/types/host.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
@@ -36,6 +41,7 @@
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Host-plane registration of the first-party Cordis inspect providers. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { hostInspectProviders } from './providers.ts'
|
||||
|
||||
export const name = 'cordis-inspect-providers'
|
||||
/** Required services: the Host inspect registry and the Tool registry the `Tool` provider reads. */
|
||||
export const inject = ['cordisInspect', 'tools']
|
||||
|
||||
/**
|
||||
* Register the Host inspect providers once per process. The registry keys
|
||||
* providers by id and rejects a duplicate, so this row belongs to the host
|
||||
* composition beside `cordis-host-runner`; every per-session `tool-cordis`
|
||||
* row reads the shared set through its tools.
|
||||
* @param ctx Host composition context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
for (const provider of hostInspectProviders(ctx)) {
|
||||
ctx.effect(() => ctx.cordisInspect.register(provider), `cordis-inspect-providers: ${provider.manifest.id}`)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { presentInspectListCall, presentInspectQueryCall } from './present.ts'
|
||||
import { hostInspectProviders } from './providers.ts'
|
||||
|
||||
export const name = 'tool-cordis'
|
||||
export const inject = ['tools', 'cordisInspect']
|
||||
@@ -15,13 +14,11 @@ function requireAgent(exec: ToolExecution): Agent {
|
||||
return exec.agent
|
||||
}
|
||||
|
||||
/** Register read-only runtime inspection tools.
|
||||
/** Register read-only runtime inspection tools over the Host providers that
|
||||
* `@deepseek-ai/dsh-tool-cordis/host` registers once per process.
|
||||
* @param ctx Agent-scoped registration context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
for (const provider of hostInspectProviders(ctx)) {
|
||||
ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`)
|
||||
}
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect_list',
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { CordisInspectRegistryService } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as CordisInspectProviders from '../src/host.ts'
|
||||
|
||||
/**
|
||||
* The Host inspect providers are one process-global set: the host entry
|
||||
* registers them beside the registry, and the per-session tool rows only read
|
||||
* them. Mounting several presets that carry `tool-cordis` over the shared
|
||||
* registry is covered by `apps/cli/tests/web-agent-presets.e2e.ts`.
|
||||
*/
|
||||
|
||||
async function host(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
// The tool registry injects `systemPrompt`; nothing under test registers a prompt section.
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(CordisInspectRegistryService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('the cordis-inspect-providers host entry', () => {
|
||||
it('registers the first-party Host providers and withdraws them on disposal', async () => {
|
||||
const ctx = await host()
|
||||
const fiber = ctx.plugin(CordisInspectProviders)
|
||||
await fiber
|
||||
|
||||
expect(ctx.cordisInspect.list().map(provider => provider.id)).toEqual(['Service', 'Event', 'Builtin', 'Tool'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.cordisInspect.list()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -263,5 +263,8 @@
|
||||
- id: present
|
||||
name: '@deepseek-ai/dsh-tool-present'
|
||||
|
||||
# Enabled exactly when the host `plugin-manager` service is (`dsh-base` gates
|
||||
# it on a launched profile), so a profile-less embedding still mounts the preset.
|
||||
- id: tool-plugin-manager
|
||||
name: '@deepseek-ai/dsh-plugin-manager/tools'
|
||||
disabled: !!js "!ctx.get('profileContext')"
|
||||
@@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { evaluate, isJsExpr } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -153,6 +154,19 @@ describe('the shipped preset root', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('enables the cordis plugin-manager tool exactly when a launched profile provides profileContext', async () => {
|
||||
// Same condition as the host `plugin-manager` row in `dsh-base`: the tool
|
||||
// injects `pluginManager`, so without that service the row must be
|
||||
// disabled rather than left waiting and blocking the whole mount.
|
||||
const disabled = findEntry(await shippedEntries('cordis'), 'tool-plugin-manager')?.disabled
|
||||
if (!isJsExpr(disabled)) throw new TypeError('cordis tool-plugin-manager must gate on a !!js expression')
|
||||
const scope = (profileContext: object | undefined) => ({
|
||||
ctx: { get: (name: string) => (name === 'profileContext' ? profileContext : undefined) },
|
||||
})
|
||||
expect(evaluate(scope(undefined), disabled.__jsExpr)).toBe(true)
|
||||
expect(evaluate(scope({ name: 'web' }), disabled.__jsExpr)).toBe(false)
|
||||
})
|
||||
|
||||
it('disables the ralph tool in every shipped preset that carries it', async () => {
|
||||
for (const id of ['cordis', 'ptc', 'standard']) {
|
||||
expect(findEntry(await shippedEntries(id), 'tool-ralph')?.disabled, id).toBe(true)
|
||||
|
||||
@@ -2372,6 +2372,9 @@ importers:
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess
|
||||
'@deepseek-ai/dsh-tool-cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../extensions/tool-cordis
|
||||
'@deepseek-ai/dsh-tool-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../../subagent/tool-subagent
|
||||
@@ -7131,6 +7134,9 @@ importers:
|
||||
'@deepseek-ai/dsh-cordis-host-runner':
|
||||
specifier: workspace:^
|
||||
version: link:../cordis-host-runner
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaninLine truncated
|
||||
"content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaninLine truncated
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
- insert:
|
||||
- id: cordis-host-runner
|
||||
name: '@deepseek-ai/dsh-cordis-host-runner'
|
||||
- id: cordis-inspect-providers
|
||||
name: '@deepseek-ai/dsh-tool-cordis/host'
|
||||
- id: tool-cordis
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
- id: llm-replay
|
||||
|
||||
@@ -33,5 +33,7 @@
|
||||
- insert:
|
||||
- id: cordis-host-runner
|
||||
name: '@deepseek-ai/dsh-cordis-host-runner'
|
||||
- id: cordis-inspect-providers
|
||||
name: '@deepseek-ai/dsh-tool-cordis/host'
|
||||
- id: tool-cordis
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
@@ -185,6 +185,7 @@
|
||||
"@deepseek-ai/dsh-plugin-manager/registry": ["./packages/boot/plugin-manager/src/registry.ts"],
|
||||
"@deepseek-ai/dsh-plugin-manager/operations": ["./packages/boot/plugin-manager/src/operations.ts"],
|
||||
"@deepseek-ai/dsh-plugin-manager/tools": ["./packages/boot/plugin-manager/src/tools.ts"],
|
||||
"@deepseek-ai/dsh-tool-cordis/host": ["./packages/extensions/tool-cordis/src/host.ts"],
|
||||
"@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"],
|
||||
"@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"],
|
||||
"@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
|
||||
|
||||