refactor(tool-bailian-kb): 重构默认服务配置管理及设置页同步逻辑

- 替换默认服务配置字段为 defaultRetrieveAgentId 和 defaultChatAgentId,支持分别管理检索和对话服务
- 增加 Bailian knowledge base 插件设置命名空间,配置支持用户层叠覆盖和实时生效
- 统一凭证与设置配置的回退链,新增一阶段设置用户层优先读取字段
- 实现凭证值向设置文档的单次迁移,辅助设置页首次展示历史值
- Bailian 页面控制器支持基于设置作用域的双域同步,回显设置字段且支持删除操作
- 工具代码切换为从设置作用域异步解析默认服务 id,agent_id 在运行时均为可选但调用时必填
- README 与文档更新,包含配置项扩展、回退链说明以及 UI 配置页操作说明
- 底层依赖由 `dsh-client-ui-settings-plugins` 切换为 `dsh-client-ui-settings`
- 修正 patch 配置与 API 参数命名,完善错误提示和超时配置读取
This commit is contained in:
zeyu.fz
2026-08-17 13:52:05 +08:00
parent 589e3a4f14
commit 8a09881626
14 changed files with 584 additions and 355 deletions
+18 -17
View File
@@ -1,6 +1,6 @@
# bailian-kb-dsh(分发包)
dsh bundle 分发面:`package.json` 的 `dsh.bundle.patch` 声明 + [`cordis.patch.yml`](cordis.patch.yml),向 profile 插入 `tool-bailian-kb` row,并随包分发浏览器端配置卡片(`dsh.client` → `lib/client.js`)。
dsh bundle 分发面:`package.json` 的 `dsh.bundle.patch` 声明 + [`cordis.patch.yml`](cordis.patch.yml),向 profile 插入 `tool-bailian-kb` row,并随包分发浏览器端配置页(`dsh.client` → `lib/client.js`)。
## Patch row
@@ -12,37 +12,38 @@ dsh bundle 分发面:`package.json` 的 `dsh.bundle.patch` 声明 + [`cordis.p
workspaceId: !!js process.env.BAILIAN_WORKSPACE_ID
```
`workspaceId` 只是解析链的一层,不是唯一来源:config 显式值(含此环境变量读取)per-call 优先;未设置时回退到 `BAILIAN_WORKSPACE_ID` credential。同样回退覆盖 `defaultAgentId`(`BAILIAN_DEFAULT_AGENT_ID`)与 API key(`DASHSCOPE_API_KEY`)。
`workspaceId` 只是解析链的一层,不是唯一来源:Config 同时注册为 `bailian-kb` settings namespace,patch entry 作 base 层,设置页/设置文档的用户层叠在其上;都未设置时 per-call 回退到 `BAILIAN_WORKSPACE_ID` credential。同样回退覆盖 `defaultRetrieveAgentId`(`BAILIAN_DEFAULT_RETRIEVE_AGENT_ID`)、`defaultChatAgentId`(`BAILIAN_DEFAULT_CHAT_AGENT_ID`)与 API key(`DASHSCOPE_API_KEY`,无 settings 面)。
## 三个值的解析链
## 四个值的解析链
| 值 | 1️⃣ config 显式值(本 patch 或用户覆盖) | 2️⃣ credential(UI 卡片 / `~/.dsh/.credentials.yaml`) | 3️⃣ 都缺失时 |
|---|---|---|---|
| `DASHSCOPE_API_KEY` | —(无 config 面) | ✅ | 工具调用报错并引导配置 |
| `BAILIAN_WORKSPACE_ID` | `workspaceId` | ✅ | 工具调用报错并引导配置 |
| `BAILIAN_DEFAULT_AGENT_ID` | `defaultAgentId` | ✅ | `agent_id` 参数变必填(schema 恒 optional,运行时校验) |
| 值 | 1️⃣ settings 用户层(设置页可编辑、回显) | 2️⃣ entry config(本 patch 或用户覆盖,作 base 层) | 3️⃣ credential(`~/.dsh/.credentials.yaml` / env) | 4️⃣ 都缺失时 |
|---|---|---|---|---|
| `DASHSCOPE_API_KEY` | —(无 settings 面) | —(无 config 面) | ✅ | 工具调用报错并引导配置 |
| `BAILIAN_WORKSPACE_ID` | ✅ `workspaceId` | ✅ `workspaceId` | ✅ | 工具调用报错并引导配置 |
| `BAILIAN_DEFAULT_RETRIEVE_AGENT_ID` | ✅ `defaultRetrieveAgentId` | ✅ `defaultRetrieveAgentId` | ✅ | `kb_search` 的 `agent_id` 参数变必填(schema 恒 optional,运行时校验) |
| `BAILIAN_DEFAULT_CHAT_AGENT_ID` | ✅ `defaultChatAgentId` | ✅ `defaultChatAgentId` | ✅ | `kb_chat` 的 `agent_id` 参数变必填(schema 恒 optional,运行时校验) |
行为参数(`endpointHost`/`agentVersion`/`chatTimeoutMs`)只在 config 层,见 [tool-bailian-kb README](../tool-bailian-kb/README.md)。
行为参数(`endpointHost`/`agentVersion`/`chatTimeoutMs`)在 config/settings 层(设置文档可改,实时生效),见 [tool-bailian-kb README](../tool-bailian-kb/README.md)。
## Web UI 配置卡片
## Web UI 配置页
装进 profile 后,Settings → Plugins 出现“百炼知识库”卡片,可配置三个 credential(写 `~/.dsh/.credentials.yaml`):
装进 profile 后,Settings 左侧导航出现“百炼知识库”页(`settings.section` 槽位):
- **DashScope API Key** — write-only,`type=password` 遮罩输入草稿
- **Bailian Workspace ID** — 明文(便于粘贴核对 workspace id)
- **默认服务 ID(agent_id)** — 明文,附独立“清除”按钮(留空保存 = 不写,清除须显式 unset)
- **DashScope API Key** — write-only(凭据域 wire 结构上无值位,永不回显),`type=password` 遮罩输入草稿,仅显示 configured/来自环境变量 徽标;写 `~/.dsh/.credentials.yaml`
- **Bailian Workspace ID / 默认检索服务 ID / 默认对话服务 ID** — **回显**:读写 `bailian-kb` settings 用户层(设置文档),预填当前解析值;清空保存 = 移除用户层,回退 entry config → credential;每个默认服务 ID 附“清除”按钮(同时 unset settings 用户层与 credential,避免回退链复活旧值)
值永不回显:字段始终空白起步,仅显示 configured/来自环境变量 徽标;来自 shell export 或 `~/.dsh/.env` 的值只读(继承环境层),输入框禁用。
降级:远程浏览器(非 loopback,settings RPC 不可达)或未组合 settings 服务时,ID 字段退回旧的 write-only credential 控件,页面顶部显示提示。
## 用户覆盖
用户 patch 层在本 bundle 之上,按 id 覆盖时**替换整个 config(无 deep-merge)**。`workspaceId`/`defaultAgentId` 均为可选,只需重述想显式固定的字段:
用户 patch 层在本 bundle 之上,按 id 覆盖时**替换整个 config(无 deep-merge)**,覆盖后的 config 成为 settings namespace 的新 base 层(设置页的用户层仍叠在其上)。`workspaceId`/`defaultRetrieveAgentId`/`defaultChatAgentId` 均为可选,只需重述想显式固定的字段:
```yaml
# ~/.dsh/cordis.patch.yml 或 profile 的 cordis.patch.yml
- id: tool-bailian-kb
config:
defaultAgentId: aid-customer-service # 场景固定式部署;省略 workspaceId 走 credential
defaultRetrieveAgentId: aid-search-service # 检索服务;省略 workspaceId 走 credential
defaultChatAgentId: aid-chat-service # 对话服务
chatTimeoutMs: 600000
```
+3 -2
View File
@@ -2,8 +2,9 @@
# workspaceId here is one resolution layer, not the only one: a config value
# (this env read included) wins per call; when it is unset the plugin resolves
# the BAILIAN_WORKSPACE_ID credential instead (web UI card or
# ~/.dsh/.credentials.yaml). The same fallback covers defaultAgentId via
# BAILIAN_DEFAULT_AGENT_ID, and the API key via DASHSCOPE_API_KEY.
# ~/.dsh/.credentials.yaml). The same fallback covers defaultRetrieveAgentId
# via BAILIAN_DEFAULT_RETRIEVE_AGENT_ID, defaultChatAgentId via
# BAILIAN_DEFAULT_CHAT_AGENT_ID, and the API key via DASHSCOPE_API_KEY.
- insert:
- id: tool-bailian-kb
+8 -5
View File
@@ -4,23 +4,26 @@
## Config
Config 同时注册为 `bailian-kb` settings namespace(`installSettingsSection`):profile patch 的 entry config 作为 base 层,用户在设置页/设置文档的修改叠在其上且实时生效(所有值每次调用经 source thunk 读取,无需重启或重注册工具)。
| 字段 | 类型 | 默认 | 语义 |
|---|---|---|---|
| `workspaceId` | string | **必填** | 百炼工作空间 id;API host 为 workspace 子域名 `https://<workspaceId>.<endpointHost>` |
| `workspaceId` | string? | — | 百炼工作空间 id;API host 为 workspace 子域名 `https://<workspaceId>.<endpointHost>`。未设置时每次调用回退 `BAILIAN_WORKSPACE_ID` credential |
| `endpointHost` | string | `cn-beijing.maas.aliyuncs.com` | host 后缀,其他 region/私有化时替换 |
| `defaultAgentId` | string? | — | 场景固定式部署绑定的检索服务;**配置后 `agent_id` 参数在注册期变为可选**(加载期静态决定 schema,非运行时 fallback) |
| `defaultRetrieveAgentId` | string? | — | 默认检索服务;`kb_search` 的 `agent_id` 参数 schema 恒可选,默认值每次调用运行时解析(settings/config → credential) |
| `defaultChatAgentId` | string? | — | 默认对话服务;`kb_chat` 的 `agent_id` 参数 schema 恒可选,默认值每次调用运行时解析(settings/config → credential) |
| `agentVersion` | string? | — | `beta`(草稿调试)或已发布版本号;不暴露给模型 |
| `chatTimeoutMs` | number | 300000 | kb_chat 超时;服务端是分钟级 agentic loop |
凭证:`DASHSCOPE_API_KEY` 走 `ctx.credentials` 引用,每次调用重新解析(热更换生效),未配置时报错并附获取指引。
凭证与回退链:`DASHSCOPE_API_KEY` 只走 `ctx.credentials` 引用(write-only,每次调用重新解析,热更换生效);`workspaceId`/`defaultRetrieveAgentId`/`defaultChatAgentId` 先取 settings 解析值(用户层 > entry config),缺失时回退同名 credential(`BAILIAN_WORKSPACE_ID`/`BAILIAN_DEFAULT_RETRIEVE_AGENT_ID`/`BAILIAN_DEFAULT_CHAT_AGENT_ID`),都没有时报错并附配置指引。
## 工具
| 工具 | 参数 | 返回 |
|---|---|---|
| `kb_service_list` | `scene?`(chat\|search,省略查双场景合并)、`name_filter?` | 服务清单(agent_id、名称、scene、status、绑定知识库)+ total + truncated;分页内部消化(单 scene 100 条上限) |
| `kb_search` | `query`、`agent_id`(见 defaultAgentId)、`top_k?`(默认 5,**客户端截断**——服务端无此参数)、`images?` | chunks(text/score/来源)+ total |
| `kb_chat` | `message`、`agent_id` | 完整答案(内部消费 SSE 流缓冲返回)+ request_id |
| `kb_search` | `query`、`agent_id`(见 defaultRetrieveAgentId)、`top_k?`(默认 5,**客户端截断**——服务端无此参数)、`images?` | chunks(text/score/来源)+ total |
| `kb_chat` | `message`、`agent_id`(见 defaultChatAgentId) | 完整答案(内部消费 SSE 流缓冲返回)+ request_id |
## 错误语义
+5 -5
View File
@@ -17,7 +17,7 @@
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-settings-plugins"
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
}
@@ -28,14 +28,14 @@
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-tools": "*",
"@deepseek-ai/dsh-credentials": "*",
"@deepseek-ai/dsh-settings": "*",
"@deepseek-ai/dsh-skill": "*",
"@deepseek-ai/schemastery": "^3.18.1",
"@deepseek-ai/dsh-api-remotes": "*",
"@deepseek-ai/dsh-client-connection": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-client-runtime": "*",
"@deepseek-ai/dsh-client-ui-settings-plugins": "*",
"@deepseek-ai/dsh-client-ui-primitives": "*",
"@deepseek-ai/dsh-client-ui-settings": "*",
"@deepseek-ai/dsh-client-ui-slots": "*",
"react": "^18.2.0"
},
@@ -43,14 +43,14 @@
"@deepseek-ai/cordis": "link:../../../deepseek-harness/vendor/cordis",
"@deepseek-ai/dsh-tools": "link:../../../deepseek-harness/packages/core/tools",
"@deepseek-ai/dsh-credentials": "link:../../../deepseek-harness/packages/credentials/credentials",
"@deepseek-ai/dsh-settings": "link:../../../deepseek-harness/packages/settings/settings",
"@deepseek-ai/dsh-skill": "link:../../../deepseek-harness/packages/skill/skill",
"@deepseek-ai/schemastery": "link:../../../deepseek-harness/vendor/schemastery",
"@deepseek-ai/dsh-api-remotes": "link:../../../deepseek-harness/packages/api/remotes",
"@deepseek-ai/dsh-client-connection": "link:../../../deepseek-harness/packages/client/connection",
"@deepseek-ai/dsh-client-locale": "link:../../../deepseek-harness/packages/client/locale",
"@deepseek-ai/dsh-client-runtime": "link:../../../deepseek-harness/packages/client/runtime",
"@deepseek-ai/dsh-client-ui-settings-plugins": "link:../../../deepseek-harness/packages/client/ui-settings-plugins",
"@deepseek-ai/dsh-client-ui-primitives": "link:../../../deepseek-harness/packages/client/ui-primitives",
"@deepseek-ai/dsh-client-ui-settings": "link:../../../deepseek-harness/packages/client/ui-settings",
"@deepseek-ai/dsh-client-ui-slots": "link:../../../deepseek-harness/packages/client/ui-slots",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
+99 -30
View File
@@ -7,6 +7,7 @@
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { settingsNamespace, type SettingsRegisterOptions, type SettingsScope } from '@deepseek-ai/dsh-settings'
import { KbClient } from './client.js'
import { registerSkill } from './skill.js'
import { createKbTools } from './tools.js'
@@ -14,58 +15,120 @@ import { createKbTools } from './tools.js'
export const name = 'tool-bailian-kb'
export const inject = ['tools', 'credentials']
/** Settings namespace this plugin registers when a settings service is composed. */
const SETTINGS_NS = settingsNamespace('bailian-kb')
/** Settings fields seeded once from their credential references ({@link seedFromCredentials}). */
const CREDENTIAL_SEEDS = [
['workspaceId', 'BAILIAN_WORKSPACE_ID'],
['defaultRetrieveAgentId', 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID'],
['defaultChatAgentId', 'BAILIAN_DEFAULT_CHAT_AGENT_ID'],
] as const
/**
* One-time migration: before this section existed, the workspace and
* default-service ids lived only as credentials, which the wire never echoes.
* Seed each field the resolved section does not answer from the WRITABLE
* credential layer (`file`), so the page shows the value the deployment
* already runs with; env-sourced values stay where they are — freezing one
* into the document would shadow later environment changes.
* @param ctx - registrant context carrying credentials.
* @param scope - the registered `bailian-kb` scope the seed writes through.
*/
async function seedFromCredentials(ctx: Context, scope: SettingsScope<Config>): Promise<void> {
try {
const seeds: Partial<Record<(typeof CREDENTIAL_SEEDS)[number][0], string>> = {}
for (const [field, ref] of CREDENTIAL_SEEDS) {
if (scope.get()[field]) continue
const resolved = await ctx.credentials.resolve(credentialRef(ref))
if (resolved?.source !== 'file') continue
seeds[field] = resolved.value
}
if (Object.keys(seeds).length > 0) await scope.update(seeds)
} catch (_migrationFailure) {
// Best-effort: a failed seed leaves the credential fallback in place, so
// resolution still answers — the page merely starts blank.
}
}
/** Bailian knowledge-base plugin configuration. */
export interface Config {
/** Bailian workspace id; the API host is the workspace subdomain `https://<workspaceId>.<endpointHost>`. Optional here: an unset value falls back per call to the BAILIAN_WORKSPACE_ID credential (Settings → Plugins card or ~/.dsh/.credentials.yaml). */
/** Bailian workspace id; the API host is the workspace subdomain `https://<workspaceId>.<endpointHost>`. Optional here: an unset value falls back per call to the BAILIAN_WORKSPACE_ID credential (env/.env or ~/.dsh/.credentials.yaml). Editable with echo on the Settings → 百炼知识库 page (settings layer). */
workspaceId?: string
/** API host suffix; replace for other regions or private deployments. */
endpointHost: string
/** Retrieval-service id pinned by this deployment; when unset, the per-call fallback reads the BAILIAN_DEFAULT_AGENT_ID credential. */
defaultAgentId?: string
/** Retrieval-service id pinned by this deployment; when unset, the per-call fallback reads the BAILIAN_DEFAULT_RETRIEVE_AGENT_ID credential. */
defaultRetrieveAgentId?: string
/** Q&A-service id pinned by this deployment; when unset, the per-call fallback reads the BAILIAN_DEFAULT_CHAT_AGENT_ID credential. */
defaultChatAgentId?: string
/** Service version to call: `beta` (draft) or a published number; defaults to the latest published version. Never model-visible. */
agentVersion?: string
/** kb_chat timeout in milliseconds; the server side is a minutes-scale agentic loop. */
chatTimeoutMs: number
}
/** Schemastery validation for {@link Config}; workspaceId and defaultAgentId are optional — both resolve per call with a credentials fallback. */
/** Schemastery validation for {@link Config}; workspaceId and default agent ids are optional — both resolve per call with a credentials fallback. */
export const Config: z<Config> = z.object({
workspaceId: z.string(),
endpointHost: z.string().default('cn-beijing.maas.aliyuncs.com'),
defaultAgentId: z.string(),
defaultRetrieveAgentId: z.string(),
defaultChatAgentId: z.string(),
agentVersion: z.string(),
chatTimeoutMs: z.number().default(300_000),
})
/**
* Register the three knowledge tools over one shared client, plus the
* management skill when a skills registry is composed.
* management skill when a skills registry is composed. The Config doubles as
* the `bailian-kb` settings section (entry config as the base layer), so
* every value is read through the live source thunk per call — tool schemas
* are static (agent_id stays optional regardless), so a settings edit needs
* no re-registration.
* @param ctx - registrant context carrying tools and credentials.
* @param config - deployment's workspace, host, pinning, and timeout choices.
*/
export function apply(ctx: Context, config: Config): void {
const pinnedWorkspaceId = config.workspaceId
const pinnedAgentId = config.defaultAgentId
// The active configuration source: the composition entry until a settings
// service attaches, then the resolved section (schema defaults → entry
// base → user layer). Detach falls back to the entry automatically.
// Hand-rolled instead of `installSettingsSection` for two extras it does
// not carry: the `expose` opt-in (this page edits the section from the
// browser) and the scope handle the credential migration writes through.
let current: () => Config = () => config
ctx.inject(['settings'], (sctx) => {
// `expose` is the wire opt-in the harness documents as deferred work; the
// assertion keeps this compiling against pristine upstream types, which do
// not declare it yet. Until upstream lands it the option is ignored and
// the browser page degrades to its credentials-only fallback.
const options = { base: config, expose: true } as SettingsRegisterOptions<Config>
const scope = sctx.settings.register(SETTINGS_NS, Config, options)
current = () => scope.get()
sctx.effect(() => () => { current = () => config }, 'tool-bailian-kb: settings source fallback')
void seedFromCredentials(ctx, scope)
})
const client = new KbClient({
resolveWorkspaceId: pinnedWorkspaceId === undefined
? async () => {
const resolved = await ctx.credentials.resolve(credentialRef('BAILIAN_WORKSPACE_ID'))
if (!resolved) {
throw new Error(
'BAILIAN_WORKSPACE_ID is not configured. Set it in the web UI (Settings → Plugins → Bailian knowledge base) '
+ 'or in ~/.dsh/.credentials.yaml; the workspace id appears as the subdomain of your Bailian endpoints.',
)
}
return resolved.value
}
: async () => pinnedWorkspaceId,
endpointHost: config.endpointHost,
...(config.agentVersion ? { agentVersion: config.agentVersion } : {}),
resolveWorkspaceId: async () => {
const pinned = current().workspaceId
if (pinned) return pinned
const resolved = await ctx.credentials.resolve(credentialRef('BAILIAN_WORKSPACE_ID'))
if (!resolved) {
throw new Error(
'BAILIAN_WORKSPACE_ID is not configured. Set the workspace id in the web UI (Settings → 百炼知识库) '
+ 'or in ~/.dsh/.credentials.yaml; it appears as the subdomain of your Bailian endpoints.',
)
}
return resolved.value
},
// Live settings reads: the client keeps no copy, so a committed edit to
// the section applies on the next call.
get endpointHost() { return current().endpointHost },
get agentVersion() { return current().agentVersion },
resolveApiKey: async () => {
const resolved = await ctx.credentials.resolve(credentialRef('DASHSCOPE_API_KEY'))
if (!resolved) {
throw new Error(
'DASHSCOPE_API_KEY is not configured. Set it in the web UI (Settings → Plugins → Bailian knowledge base) '
'DASHSCOPE_API_KEY is not configured. Set it in the web UI (Settings → 百炼知识库) '
+ 'or in ~/.dsh/.credentials.yaml (create a key at https://bailian.console.aliyun.com/?tab=app#/api-key).',
)
}
@@ -74,13 +137,19 @@ export function apply(ctx: Context, config: Config): void {
})
for (const tool of createKbTools({
client,
resolveDefaultAgentId: pinnedAgentId !== undefined
? async () => pinnedAgentId
: async () => {
const resolved = await ctx.credentials.resolve(credentialRef('BAILIAN_DEFAULT_AGENT_ID'))
return resolved?.value
},
chatTimeoutMs: config.chatTimeoutMs,
resolveDefaultRetrieveAgentId: async () => {
const pinned = current().defaultRetrieveAgentId
if (pinned) return pinned
const resolved = await ctx.credentials.resolve(credentialRef('BAILIAN_DEFAULT_RETRIEVE_AGENT_ID'))
return resolved?.value
},
resolveDefaultChatAgentId: async () => {
const pinned = current().defaultChatAgentId
if (pinned) return pinned
const resolved = await ctx.credentials.resolve(credentialRef('BAILIAN_DEFAULT_CHAT_AGENT_ID'))
return resolved?.value
},
get chatTimeoutMs() { return current().chatTimeoutMs },
})) {
ctx.tools.register(tool)
}
+23 -9
View File
@@ -1,6 +1,6 @@
/**
* The three model-facing knowledge tools. agent_id stays optional in the schema
* regardless of deployment: the default service (patch config or credential)
* regardless of deployment: the default services (patch config or credential)
* can change at runtime through the credentials domain, so the fallback runs
* per call and a missing default surfaces as an executable error instead of a
* load-time schema difference.
@@ -18,8 +18,11 @@ const DEFAULT_TOP_K = 5
export interface KbToolDeps {
client: KbClient
/** Resolves the default agent id per call (patch config or credential); omitted means no default. */
resolveDefaultAgentId?: () => Promise<string | undefined>
/** Resolves the default retrieval agent id per call (settings/patch config or credential); omitted means no default for kb_search. */
resolveDefaultRetrieveAgentId?: () => Promise<string | undefined>
/** Resolves the default chat agent id per call (settings/patch config or credential); omitted means no default for kb_chat. */
resolveDefaultChatAgentId?: () => Promise<string | undefined>
/** Read per call (a live-settings deployment supplies a getter). */
chatTimeoutMs: number
}
@@ -53,16 +56,26 @@ async function withServiceHint(client: KbClient, err: unknown): Promise<never> {
* @returns definitions ready for `ctx.tools.register()`.
*/
export function createKbTools(deps: KbToolDeps) {
const { client, resolveDefaultAgentId, chatTimeoutMs } = deps
// chatTimeoutMs is deliberately NOT destructured: reading it off deps at
// execute time keeps a live-settings getter live.
const { client, resolveDefaultRetrieveAgentId, resolveDefaultChatAgentId } = deps
const agentIdParam = {
type: 'string' as const,
description: 'Retrieval/Q&A service id; omit to use the default service when this deployment configures one (find ids via kb_service_list).',
}
const resolveAgentId = async (supplied: string | undefined): Promise<string> => {
const resolveRetrieveAgentId = async (supplied: string | undefined): Promise<string> => {
if (supplied !== undefined) return supplied
const defaultId = resolveDefaultAgentId === undefined ? undefined : await resolveDefaultAgentId()
const defaultId = resolveDefaultRetrieveAgentId === undefined ? undefined : await resolveDefaultRetrieveAgentId()
if (defaultId === undefined) {
throw new Error('agent_id is required: no default service is configured; discover services with kb_service_list')
throw new Error('agent_id is required: no default retrieval service is configured; discover services with kb_service_list')
}
return defaultId
}
const resolveChatAgentId = async (supplied: string | undefined): Promise<string> => {
if (supplied !== undefined) return supplied
const defaultId = resolveDefaultChatAgentId === undefined ? undefined : await resolveDefaultChatAgentId()
if (defaultId === undefined) {
throw new Error('agent_id is required: no default chat service is configured; discover services with kb_service_list')
}
return defaultId
}
@@ -167,7 +180,7 @@ export function createKbTools(deps: KbToolDeps) {
const topK = args.top_k ?? DEFAULT_TOP_K
const body: SearchRequest = {
query: args.query,
agent_id: await resolveAgentId(args.agent_id),
agent_id: await resolveRetrieveAgentId(args.agent_id),
...(client.agentVersion ? { agent_version: client.agentVersion } : {}),
...(args.images && args.images.length > 0 ? { images: args.images } : {}),
}
@@ -211,10 +224,11 @@ export function createKbTools(deps: KbToolDeps) {
render: (_args, value) => [{ type: 'text', text: value.answer.length === 0 ? '(empty answer)' : value.answer }],
},
async execute(args) {
const chatTimeoutMs = deps.chatTimeoutMs
const body = {
input: { messages: [{ role: 'user' as const, content: args.message }] },
parameters: { agent_options: {
agent_id: await resolveAgentId(args.agent_id),
agent_id: await resolveChatAgentId(args.agent_id),
...(client.agentVersion ? { agent_version: client.agentVersion } : {}),
} },
stream: true as const,
@@ -1,85 +1,47 @@
/* Bailian card: header, credential fields, clear control, and save footer.
Mirrors the host plugin-card chrome (an out-of-tree bundle cannot value-
import the host card components, only their platform primitives). */
/* Bailian settings page: title row, credential fields, clear control, and
save footer, in the settings-panel design language (16/24 title, 14/22
intro, `--dsw-alias-*` tokens — an out-of-tree bundle cannot value-import
the host section components, only their platform primitives). */
.card {
list-style: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color .16s, background .16s;
}
.card:hover {
border-color: var(--dsw-alias-label-dimmed);
}
/* An open card reads as the one being worked on, not merely taller. */
.cardOpen {
background: var(--dsw-alias-bg-layer-2);
border-color: var(--dsw-alias-label-dimmed);
}
.header {
width: 100%;
appearance: none;
border: 0;
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
}
.header:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: -2px;
}
/* Name over description: the description is what tells two plugins apart. */
.headText {
flex: 1;
min-width: 0;
.section {
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.description {
font-size: 13px;
line-height: 1.5;
.headRow {
display: flex;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.intro {
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-tertiary);
}
.chevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
transition: transform .16s;
/* The three controls grouped as one outlined object on the panel fill. */
.form {
margin-top: 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
padding: 4px 16px 12px;
background: var(--dsw-alias-bg-layer-3);
}
.chevronOpen {
transform: rotate(180deg);
}
.body {
border-top: 1px solid var(--dsw-alias-border-l2);
margin: 0 16px;
padding-bottom: 8px;
}
/* Carried on the header so a collapsed card still says it holds edits. */
/* Carried beside the title so a scrolled page still says it holds edits. */
.pending {
flex: none;
border-radius: 999px;
@@ -92,6 +54,14 @@
color: var(--dsw-alias-label-secondary);
}
/* Degraded-transport notice: the write-only fallback explains itself once. */
.notice {
margin: 8px 0 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-warn-label);
}
.field {
display: flex;
flex-direction: column;
@@ -197,7 +167,7 @@
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 0 4px;
padding: 12px 0 0;
border-top: 1px solid var(--dsw-alias-border-l2);
}
+112 -100
View File
@@ -1,22 +1,24 @@
/**
* The Bailian knowledge-base card: three write-only credential controls plus
* the default-service clear. Values never ride a response, so each control
* starts blank and reports only configured/unconfigured; the API key drafts
* behind a password mask while the workspace and agent ids draft in the clear
* — they are pasted identifiers, not secrets, and a visible draft can be
* proofread.
* The Bailian knowledge-base settings page: one section page in the
* Settings left nav. The workspace, default-retrieval-service and
* default-chat-service ids echo from the `bailian-kb` settings section
* while the scope is ready (clearing one falls back down the resolution
* chain), and degrade to write-only credential controls otherwise; the
* API key is always write-only — it drafts behind a password mask,
* starts blank, and reports only configured/unconfigured.
*/
import { useState } from 'react'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BAILIAN_CARD_REFS, type BailianCardFace, type BailianFieldKey } from './bailian-card-controller.ts'
import {
BAILIAN_CARD_REFS, dirtyOf, echoedValue, SETTINGS_FIELDS,
type BailianCardFace, type BailianFieldKey,
} from './bailian-card-controller.ts'
import type { BailianKbLocaleKey } from './locales.ts'
import css from './BailianCard.module.css'
/** Props the renderer binds for the Bailian card. */
/** Props the renderer binds for the Bailian section page. */
export type BailianCardProps =
PropsRuntime<'settings.plugin.item'>
PropsRuntime<'settings.section'>
& PropsLocale<'tool-bailian-kb'>
& InjectFace<BailianCardFace>
@@ -24,113 +26,123 @@ export type BailianCardProps =
interface FieldView {
key: BailianFieldKey
labelKey: BailianKbLocaleKey
/** Echo-mode explanation (settings-backed value, blank save = fall back). */
hintKey: BailianKbLocaleKey
/** Write-only explanation (credential store, blank = keep the stored value). */
fallbackHintKey: BailianKbLocaleKey
setKey: BailianKbLocaleKey
unsetKey: BailianKbLocaleKey
/** Password-masked drafting; only the API key is an actual secret. */
secret: boolean
}
/** The three controls, in card order. */
/** The controls, in page order. */
const FIELDS: readonly FieldView[] = [
{ key: 'DASHSCOPE_API_KEY', labelKey: 'apiKey', hintKey: 'apiKeyHint', setKey: 'apiKeySet', unsetKey: 'apiKeyUnset', secret: true },
{ key: 'BAILIAN_WORKSPACE_ID', labelKey: 'workspaceId', hintKey: 'workspaceIdHint', setKey: 'workspaceIdSet', unsetKey: 'workspaceIdUnset', secret: false },
{ key: 'BAILIAN_DEFAULT_AGENT_ID', labelKey: 'agentId', hintKey: 'agentIdHint', setKey: 'agentIdSet', unsetKey: 'agentIdUnset', secret: false },
{ key: 'DASHSCOPE_API_KEY', labelKey: 'apiKey', hintKey: 'apiKeyHint', fallbackHintKey: 'apiKeyHint', setKey: 'apiKeySet', unsetKey: 'apiKeyUnset', secret: true },
{ key: 'BAILIAN_WORKSPACE_ID', labelKey: 'workspaceId', hintKey: 'workspaceIdHint', fallbackHintKey: 'workspaceIdHintFallback', setKey: 'workspaceIdSet', unsetKey: 'workspaceIdUnset', secret: false },
{ key: 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID', labelKey: 'retrieveAgentId', hintKey: 'retrieveAgentIdHint', fallbackHintKey: 'retrieveAgentIdHintFallback', setKey: 'retrieveAgentIdSet', unsetKey: 'retrieveAgentIdUnset', secret: false },
{ key: 'BAILIAN_DEFAULT_CHAT_AGENT_ID', labelKey: 'chatAgentId', hintKey: 'chatAgentIdHint', fallbackHintKey: 'chatAgentIdHintFallback', setKey: 'chatAgentIdSet', unsetKey: 'chatAgentIdUnset', secret: false },
]
/**
* Render the Bailian card.
* @param props - locale copy, the card snapshot, and its actions.
* @returns the card.
* Render the Bailian section page.
* @param props - locale copy, the page snapshot, and its actions.
* @returns the section page.
*/
export function BailianCard(props: BailianCardProps) {
const { t } = props
const state = props.useBailianCard(snapshot => snapshot)
const [open, setOpen] = useState(false)
const dirty = BAILIAN_CARD_REFS.some(key => state.drafts[key] !== '')
const dirty = dirtyOf(state)
const busy = state.saving || state.clearing
return (
<li className={css.card + (open ? ` ${css.cardOpen}` : '')}>
<button
type="button"
className={css.header}
aria-expanded={open}
aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${props.t('title')}`}
onClick={() => { setOpen(!open) }}
>
<span className={css.headText}>
<span className={css.name}>{t('title')}</span>
<span className={css.description}>{t('description')}</span>
</span>
<section className={css.section}>
<div className={css.headRow}>
<h2 className={css.title}>{t('title')}</h2>
{dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
<IconChevronDownOutline14 className={css.chevron + (open ? ` ${css.chevronOpen}` : '')} />
</button>
{open
? (
<div className={css.body}>
{FIELDS.map(field => {
const credential = state.credentials[field.key]
// The launch environment wins and refuses writes: the badge says
// where the value lives instead of a control that cannot act.
const stateLabel = credential.configured
? (credential.writable ? t(field.setKey) : t('fromEnv'))
: t(field.unsetKey)
const showClear = field.key === 'BAILIAN_DEFAULT_AGENT_ID' && credential.configured
return (
<div className={css.field} key={field.key}>
<div className={css.head}>
<label className={css.label} htmlFor={`bailian-kb-${field.key}`}>{t(field.labelKey)}</label>
<span className={css.badges}>
{showClear
? (
<button
type="button"
className={css.clear}
disabled={busy || !credential.writable}
onClick={() => { void props.clearDefaultAgent() }}
>
{t(state.clearing ? 'clearing' : 'clear')}
</button>
)
: null}
<span className={credential.configured ? css.badge : css.badgeMuted}>{stateLabel}</span>
</span>
</div>
<input
id={`bailian-kb-${field.key}`}
className={css.input}
type={field.secret ? 'password' : 'text'}
autoComplete="off"
value={state.drafts[field.key]}
disabled={!credential.writable || busy}
onChange={(event) => { props.edit(field.key, event.target.value) }}
/>
<p className={css.hint}>{t(field.hintKey)}</p>
</div>
)
})}
<div className={css.footer}>
{state.failed ? <p className={css.failed} role="status">{t('saveFailed')}</p> : null}
<button
type="button"
className={css.discard}
disabled={!dirty || busy}
onClick={props.discard}
>
{t('discard')}
</button>
<button
type="button"
className={css.save}
disabled={!dirty || busy}
onClick={() => { void props.save() }}
>
{t(state.saving ? 'saving' : 'save')}
</button>
</div>
<p className={css.intro}>{t('description')}</p>
<div className={css.form}>
{state.settings.status === 'unavailable'
? <p className={css.notice}>{t('settingsUnavailable')}</p>
: null}
{FIELDS.map(field => {
const credential = state.credentials[field.key]
// Echo mode: the settings scope answers with the resolved value, so
// the control is an ordinary pre-filled input. Otherwise the control
// is write-only and the badge is all the state there is.
const echo = SETTINGS_FIELDS[field.key] !== undefined && state.settings.status === 'ready'
const echoed = echoedValue(state, field.key)
const value = state.drafts[field.key] ?? (echo ? echoed : '')
const disabled = busy || (echo ? !state.settings.writable : !credential.writable)
// The launch environment wins over the credential store and refuses
// writes; in echo mode a non-empty settings value shadows both, so
// the badge only reports the fallback under an empty input.
const badge = echo
? (echoed !== ''
? undefined
: credential.configured
? { label: t('fallbackConfigured'), set: true }
: { label: t(field.unsetKey), set: false })
: credential.configured
? { label: credential.writable ? t(field.setKey) : t('fromEnv'), set: true }
: { label: t(field.unsetKey), set: false }
const showClear = (field.key === 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID' || field.key === 'BAILIAN_DEFAULT_CHAT_AGENT_ID')
&& (credential.configured || (echo && echoed !== ''))
return (
<div className={css.field} key={field.key}>
<div className={css.head}>
<label className={css.label} htmlFor={`bailian-kb-${field.key}`}>{t(field.labelKey)}</label>
<span className={css.badges}>
{showClear
? (
<button
type="button"
className={css.clear}
disabled={busy}
onClick={() => { void props.clearDefaultAgent(field.key as 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID' | 'BAILIAN_DEFAULT_CHAT_AGENT_ID') }}
>
{t(state.clearing ? 'clearing' : 'clear')}
</button>
)
: null}
{badge !== undefined
? <span className={badge.set ? css.badge : css.badgeMuted}>{badge.label}</span>
: null}
</span>
</div>
<input
id={`bailian-kb-${field.key}`}
className={css.input}
type={field.secret ? 'password' : 'text'}
autoComplete="off"
value={value}
disabled={disabled}
onChange={(event) => { props.edit(field.key, event.target.value) }}
/>
<p className={css.hint}>{t(echo ? field.hintKey : field.fallbackHintKey)}</p>
</div>
</div>
)
: null}
</li>
)
})}
<div className={css.footer}>
{state.failed ? <p className={css.failed} role="status">{t('saveFailed')}</p> : null}
<button
type="button"
className={css.discard}
disabled={!dirty || busy}
onClick={props.discard}
>
{t('discard')}
</button>
<button
type="button"
className={css.save}
disabled={!dirty || busy}
onClick={() => { void props.save() }}
>
{t(state.saving ? 'saving' : 'save')}
</button>
</div>
</div>
</section>
)
}
@@ -1,29 +1,51 @@
/**
* The Bailian card's controller: staged drafts over the credentials domain.
* The Bailian page's controller: a hybrid form over two domains.
*
* All three values ride credential references (no settings namespace is
* involved — an out-of-tree package cannot expose one to the browser), so the
* card never holds a stored literal: it learns only whether each reference is
* configured and writable, stages drafts locally, and one save writes every
* non-blank draft through `credentials.set`. A blank draft writes nothing and
* keeps the stored value. The default-service reference is the one value a
* user can meaningfully remove, so it alone gets a clear action
* (`credentials.unset`), immediate rather than staged.
* The workspace, default-retrieval-service and default-chat-service ids live
* in the `bailian-kb` settings section the Host half registers, so while the
* settings scope is `ready` they ECHO: the page shows the resolved value and
* stages edits over it (clearing a field unsets the user layer, falling back
* to the entry config and then the credential store). When the scope is
* unavailable — a remote browser (memory mode) or a composition without a
* settings service — both fields degrade to the original write-only credential
* controls.
*
* The API key always rides its credential reference (write-only by design:
* the wire is structurally value-free), so that control starts blank and
* reports only configured/unconfigured.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, type SettingsScope, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** The credential references this card stages, keyed by their ref names. */
/** The credential references this page addresses, keyed by their ref names. */
export const BAILIAN_CARD_REFS = [
'DASHSCOPE_API_KEY',
'BAILIAN_WORKSPACE_ID',
'BAILIAN_DEFAULT_AGENT_ID',
'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID',
'BAILIAN_DEFAULT_CHAT_AGENT_ID',
] as const
/** One card field, addressed by its credential reference. */
/** One page field, addressed by its credential reference. */
export type BailianFieldKey = (typeof BAILIAN_CARD_REFS)[number]
/** Settings-section field names of the echoing controls. */
export type BailianSettingsField = 'workspaceId' | 'defaultRetrieveAgentId' | 'defaultChatAgentId'
/** Credential reference → settings-section field, for the hybrid controls. */
export const SETTINGS_FIELDS: Partial<Record<BailianFieldKey, BailianSettingsField>> = {
BAILIAN_WORKSPACE_ID: 'workspaceId',
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID: 'defaultRetrieveAgentId',
BAILIAN_DEFAULT_CHAT_AGENT_ID: 'defaultChatAgentId',
}
/** The section subset this page reads and writes (the namespace holds the whole plugin Config). */
export interface BailianKbSection {
workspaceId?: string
defaultRetrieveAgentId?: string
defaultChatAgentId?: string
}
/** What the credentials domain reports for one reference (never the value). */
export interface BailianCredentialView {
/** Whether any layer supplies a value for the reference. */
@@ -32,65 +54,125 @@ export interface BailianCredentialView {
writable: boolean
}
/** What the Bailian card renders. */
/** The page's mirror of the settings scope. */
export interface BailianSettingsView {
/** `ready` enables echo; `unavailable` degrades to write-only credentials. */
status: 'loading' | 'ready' | 'unavailable'
/** Whether the Host settings document accepts writes. */
writable: boolean
/** Resolved section values (entry base + user layer) for the two hybrid controls. */
values: BailianKbSection
}
/** What the Bailian page renders. */
export interface BailianCardState {
/** Staged drafts, blank = keep the stored value. */
drafts: Record<BailianFieldKey, string>
/** Staged drafts; undefined = untouched (the control shows the echoed value). */
drafts: Record<BailianFieldKey, string | undefined>
/** Last credentials-domain answer per reference; unknown refs read as writable. */
credentials: Record<BailianFieldKey, BailianCredentialView>
/** Settings-scope echo state for the id fields. */
settings: BailianSettingsView
/** Whether a save is in flight. */
saving: boolean
/** Whether the default-service clear is in flight. */
/** Whether a default-service clear is in flight. */
clearing: boolean
/** Whether the last save or clear was refused; drafts are kept for correction. */
failed: boolean
}
/** The registration-side face the card's slot entry injects. */
/** The registration-side face the page's slot entry injects. */
export interface BailianCardFace {
hooks: {
/** Card snapshot bound by the renderer as useBailianCard. */
/** Page snapshot bound by the renderer as useBailianCard. */
bailianCard: SnapshotStore<BailianCardState>
}
/** Stage one draft. */
edit: (key: BailianFieldKey, text: string) => void
/** Write every non-blank draft through `credentials.set`, then re-read. */
/** Write every staged draft through its domain, then re-read. */
save: () => Promise<void>
/** Drop every staged draft. */
discard: () => void
/** Remove the stored default service (`credentials.unset`), then re-read. */
clearDefaultAgent: () => Promise<void>
/** Remove the stored default service from every writable layer, then re-read. */
clearDefaultAgent: (key: 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID' | 'BAILIAN_DEFAULT_CHAT_AGENT_ID') => Promise<void>
}
/** Bridge the credentials domain onto the card. */
/** The text a field's control shows when its draft is untouched. */
export function echoedValue(state: BailianCardState, key: BailianFieldKey): string {
const field = SETTINGS_FIELDS[key]
if (field === undefined || state.settings.status !== 'ready') return ''
return state.settings.values[field] ?? ''
}
/** Whether one field stages a change a save would write. */
function staged(state: BailianCardState, key: BailianFieldKey): boolean {
const draft = state.drafts[key]
if (draft === undefined) return false
const field = SETTINGS_FIELDS[key]
if (field !== undefined && state.settings.status === 'ready') {
return draft !== echoedValue(state, key)
}
// Write-only control: blank means untouched, never "erase the stored value".
return draft !== ''
}
/** Whether any field stages a change (enables Save/Discard). */
export function dirtyOf(state: BailianCardState): boolean {
return BAILIAN_CARD_REFS.some(key => staged(state, key))
}
/** Bridge the settings scope and the credentials domain onto the page. */
export class BailianCardController {
private readonly store: SnapshotStore<BailianCardState>
/**
* @param api - wire face used for the three credential references.
* @param scope - the bound `bailian-kb` settings scope (echo transport).
*/
constructor(private readonly api: Pick<IApiClient, 'credentials'>) {
constructor(
private readonly api: Pick<IApiClient, 'credentials'>,
private readonly scope: SettingsScope<BailianKbSection>,
) {
this.store = createSnapshotStore<BailianCardState>({
drafts: {
DASHSCOPE_API_KEY: '',
BAILIAN_WORKSPACE_ID: '',
BAILIAN_DEFAULT_AGENT_ID: '',
DASHSCOPE_API_KEY: undefined,
BAILIAN_WORKSPACE_ID: undefined,
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID: undefined,
BAILIAN_DEFAULT_CHAT_AGENT_ID: undefined,
},
credentials: {
DASHSCOPE_API_KEY: { configured: false, writable: true },
BAILIAN_WORKSPACE_ID: { configured: false, writable: true },
BAILIAN_DEFAULT_AGENT_ID: { configured: false, writable: true },
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID: { configured: false, writable: true },
BAILIAN_DEFAULT_CHAT_AGENT_ID: { configured: false, writable: true },
},
settings: { status: 'loading', writable: false, values: {} },
saving: false,
clearing: false,
failed: false,
})
this.syncSettings()
void this.read()
}
/** Whether any draft is staged. */
get dirty(): boolean {
return BAILIAN_CARD_REFS.some(key => this.store.getSnapshot().drafts[key] !== '')
/**
* Mirror the scope snapshot into the page state. Called at construction and
* from the registration-side subscription (the scope self-refreshes on
* pushed document invalidations and connection resets).
*/
syncSettings(): void {
const snapshot = this.scope.getSnapshot()
const value = snapshot.value
this.store.update(draft => {
draft.settings = {
status: snapshot.status,
writable: snapshot.writable,
values: {
...(value?.workspaceId !== undefined ? { workspaceId: value.workspaceId } : {}),
...(value?.defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId: value.defaultRetrieveAgentId } : {}),
...(value?.defaultChatAgentId !== undefined ? { defaultChatAgentId: value.defaultChatAgentId } : {}),
},
}
})
}
/**
@@ -107,66 +189,94 @@ export class BailianCardController {
}
/**
* Write every non-blank draft, then re-read all references. A refused write
* keeps its draft: the copy tells the user the values were left to correct.
* Write every staged draft through its domain: echoing fields go to the
* settings user layer (blank = unset, falling back to entry config and the
* credential store), write-only fields go to `credentials.set`. A refused
* credential write keeps its draft; a refused settings write self-heals by
* the scope's own recovery read (the control snaps back to the Host value).
*/
async save(): Promise<void> {
const staged = new Map(
BAILIAN_CARD_REFS
.map(key => [key, this.store.getSnapshot().drafts[key]] as const)
.filter(([, text]) => text !== ''),
)
if (staged.size === 0 || this.store.getSnapshot().saving) return
const state = this.store.getSnapshot()
if (state.saving || !dirtyOf(state)) return
this.store.update(draft => { draft.saving = true })
let failed = false
await Promise.all([...staged].map(async ([ref, value]) => {
try {
const response = await this.api.credentials.set({ ref, value })
if (!response.result.ok) failed = true
} catch (_credentialWriteFailure) {
failed = true
const writes: Promise<void>[] = []
const settled: BailianFieldKey[] = []
for (const key of BAILIAN_CARD_REFS) {
if (!staged(state, key)) continue
const text = state.drafts[key] as string
const field = SETTINGS_FIELDS[key]
if (field !== undefined && state.settings.status === 'ready') {
writes.push(text === '' ? this.scope.unset(field) : this.scope.set(field, text))
settled.push(key)
continue
}
}))
writes.push((async () => {
try {
const response = await this.api.credentials.set({ ref: key, value: text })
if (response.result.ok) settled.push(key)
else failed = true
} catch (_credentialWriteFailure) {
failed = true
}
})())
}
await Promise.all(writes)
this.store.update(draft => {
draft.saving = false
draft.failed = failed
if (!failed) for (const ref of staged.keys()) draft.drafts[ref] = ''
for (const key of settled) draft.drafts[key] = undefined
})
this.syncSettings()
await this.read()
}
/** Drop every staged draft and the failure mark. */
discard(): void {
this.store.update(draft => {
for (const ref of BAILIAN_CARD_REFS) draft.drafts[ref] = ''
for (const ref of BAILIAN_CARD_REFS) draft.drafts[ref] = undefined
draft.failed = false
})
}
/** Remove the stored default service so every call names one again. */
async clearDefaultAgent(): Promise<void> {
if (this.store.getSnapshot().clearing) return
/**
* Remove the stored default service from every writable layer — the
* settings user layer AND the credential store, so the fallback chain does
* not resurrect the value the user just cleared. Both removals are
* idempotent; the credential unset is skipped when nothing is stored there.
* @param key - which default service credential to clear.
*/
async clearDefaultAgent(key: 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID' | 'BAILIAN_DEFAULT_CHAT_AGENT_ID'): Promise<void> {
const settingsField: BailianSettingsField = key === 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID' ? 'defaultRetrieveAgentId' : 'defaultChatAgentId'
const state = this.store.getSnapshot()
if (state.clearing) return
this.store.update(draft => { draft.clearing = true })
let failed = false
try {
const response = await this.api.credentials.unset({ ref: 'BAILIAN_DEFAULT_AGENT_ID' })
if (!response.result.ok) failed = true
} catch (_credentialWriteFailure) {
failed = true
if (state.settings.status === 'ready') await this.scope.unset(settingsField)
if (state.credentials[key].configured) {
try {
const response = await this.api.credentials.unset({ ref: key })
if (!response.result.ok) failed = true
} catch (_credentialWriteFailure) {
failed = true
}
}
this.store.update(draft => {
draft.clearing = false
draft.failed = failed
draft.drafts[key] = undefined
})
this.syncSettings()
await this.read()
}
/**
* Re-read after the Host reports a change to a reference this card watches.
* Re-read after the Host reports a change to a reference this page watches.
*
* A value can be written from somewhere else — the Models page addresses
* DASHSCOPE_API_KEY too, and the file store accepts external edits — so
* without this the badges keep reporting a state the Host already replaced.
* (Settings-document changes reach the page through the scope instead.)
* @param ref - the reference the Host reports as changed.
*/
refresh(ref: string): void {
@@ -175,8 +285,8 @@ export class BailianCardController {
}
/**
* Build the face the card's slot registration injects.
* @returns the card's snapshot and its actions.
* Build the face the page's slot registration injects.
* @returns the page's snapshot and its actions.
*/
inject(): BailianCardFace {
return {
@@ -184,13 +294,13 @@ export class BailianCardController {
edit: (key, text) => { this.edit(key, text) },
save: () => this.save(),
discard: () => { this.discard() },
clearDefaultAgent: () => this.clearDefaultAgent(),
clearDefaultAgent: (key) => this.clearDefaultAgent(key),
}
}
/**
* Ask the credentials domain about all three references and publish the
* answer. A failed read keeps the last known state: the card stays usable
* answer. A failed read keeps the last known state: the page stays usable
* and a write still reaches the Host.
*/
private async read(): Promise<void> {
@@ -205,7 +315,7 @@ export class BailianCardController {
this.store.update(draft => {
for (const ref of BAILIAN_CARD_REFS) {
// An unknown reference reads as writable: the control stays usable and
// the Host is what refuses, rather than the card guessing a refusal.
// the Host is what refuses, rather than the page guessing a refusal.
draft.credentials[ref] = {
configured: view[ref]?.configured ?? false,
writable: view[ref]?.writable ?? true,
+32 -16
View File
@@ -1,9 +1,11 @@
/**
* Bailian knowledge-base plugin, browser half: one card in the plugin
* configuration section staging the three credential references the Host half
* resolves per call. The card is pure credentials-domain — this package
* exposes no settings namespace (an out-of-tree package cannot get one onto
* the browser settings surface), so nothing here touches a settings scope.
* Bailian knowledge-base plugin, browser half: one section page in the
* Settings left nav. The workspace, default-retrieval-service and
* default-chat-service ids ride the `bailian-kb` settings namespace the
* Host half registers (echoing values through `ctx.settingsScope`,
* degrading to write-only credential controls when the scope is
* unavailable); the API key stays pure credentials-domain and never
* echoes.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
@@ -14,16 +16,17 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// credential-update events.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: the 'settings.plugin.item' SlotMap merge, declared by the plugins
// settings section this card registers into.
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
// The 'settings.section' SlotMap merge AND the ctx.settingsScope service,
// both declared by the settings domain base (type-only: the service arrives
// through cordis, never a value import).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import { BailianCard } from './BailianCard.tsx'
import { BailianCardController } from './bailian-card-controller.ts'
import { BailianCardController, type BailianKbSection } from './bailian-card-controller.ts'
import { en, zh, type BailianKbLocaleKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The Bailian card's copy. */
/** The Bailian section page's copy. */
'tool-bailian-kb': BailianKbLocaleKey
}
}
@@ -32,17 +35,29 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'tool-bailian-kb'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection', 'remote']
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
/**
* Mount the Bailian card into the plugin configuration section.
* Mount the Bailian section page into the Settings left nav.
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const { api } = ctx.get('connection') as ConnectionHandle
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'tool-bailian-kb: dictionaries')
const card = new BailianCardController(api)
// Registration-time text: the nav label is a thunk the shell resolves per
// render, so copy freshness rides the locale revision without re-registering.
const t = ctx.locale.bind(NS)
// The echo transport: bound on this fiber, self-refreshing on pushed
// settings-document invalidations and connection resets. A remote browser
// binds in memory mode and the page degrades to write-only controls.
const scope = ctx.settingsScope.bind<BailianKbSection>({ namespace: 'bailian-kb' })
const card = new BailianCardController(api, scope)
ctx.effect(
() => scope.subscribe(() => { card.syncSettings() }),
'tool-bailian-kb: settings echo',
)
// Values can change elsewhere (Models page, external file edits); the badges
// must follow the Host, not the card's last write.
ctx.effect(
@@ -50,10 +65,11 @@ export function apply(ctx: ClientContext): void {
'tool-bailian-kb: credential invalidations',
)
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'bailian-kb',
order: 30,
order: 20,
label: () => t('nav'),
locale: NS,
inject: () => card.inject(),
}, BailianCard))
+43 -24
View File
@@ -1,40 +1,51 @@
/**
* Locale bundles for the Bailian knowledge-base plugin card. The card rides
* the credentials domain for all three values, so every copy is written for
* write-only controls: state is reported as configured/unconfigured, and a
* stored value is never echoed back.
* Locale bundles for the Bailian knowledge-base settings page. The workspace,
* default-retrieval-service and default-chat-service ids echo from the
* settings section while it is available and fall back to write-only
* credential controls otherwise; the API key copy is always written for a
* write-only control: state is reported as configured/unconfigured, and a
* stored key is never echoed back.
*/
/** Locale keys this card renders. */
/** Locale keys this page renders. */
export type BailianKbLocaleKey =
| 'title' | 'description'
| 'nav' | 'title' | 'description' | 'settingsUnavailable' | 'fallbackConfigured'
| 'apiKey' | 'apiKeyHint' | 'apiKeySet' | 'apiKeyUnset'
| 'workspaceId' | 'workspaceIdHint' | 'workspaceIdSet' | 'workspaceIdUnset'
| 'agentId' | 'agentIdHint' | 'agentIdSet' | 'agentIdUnset'
| 'fromEnv' | 'clear' | 'clearing' | 'expand' | 'collapse'
| 'workspaceId' | 'workspaceIdHint' | 'workspaceIdHintFallback' | 'workspaceIdSet' | 'workspaceIdUnset'
| 'retrieveAgentId' | 'retrieveAgentIdHint' | 'retrieveAgentIdHintFallback' | 'retrieveAgentIdSet' | 'retrieveAgentIdUnset'
| 'chatAgentId' | 'chatAgentIdHint' | 'chatAgentIdHintFallback' | 'chatAgentIdSet' | 'chatAgentIdUnset'
| 'fromEnv' | 'clear' | 'clearing'
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed'
/** English copy. */
export const en: Record<BailianKbLocaleKey, string> = {
nav: 'Bailian KB',
title: 'Bailian knowledge base',
description: 'Account for the knowledge tools: API key, workspace, and default service.',
description: 'Account for the knowledge tools: API key, workspace, and default services.',
settingsUnavailable: 'The settings document is not reachable from this browser; values below are write-only and stored in the credential store.',
fallbackConfigured: 'Falling back to a configured credential-store value.',
apiKey: 'API key',
apiKeyHint: 'DashScope API key. Stored in the credentials store and never shown again; leave blank to keep the current one.',
apiKeySet: 'A key is configured.',
apiKeyUnset: 'No key is configured; knowledge tools fail until one is.',
workspaceId: 'Workspace id',
workspaceIdHint: 'Bailian workspace id — the subdomain of your endpoints. Leave blank to keep the current one.',
workspaceIdHint: 'Bailian workspace id — the subdomain of your endpoints. Stored in the settings document; clear and save to fall back to the credential store.',
workspaceIdHintFallback: 'Bailian workspace id — the subdomain of your endpoints. Leave blank to keep the current one.',
workspaceIdSet: 'A workspace is configured.',
workspaceIdUnset: 'No workspace is configured; knowledge tools fail until one is.',
agentId: 'Default service id',
agentIdHint: 'agent_id of the default retrieval/Q&A service; when unset, every call must name one (kb_service_list discovers ids). Leave blank to keep the current one.',
agentIdSet: 'A default service is configured.',
agentIdUnset: 'No default service; every call must name one.',
retrieveAgentId: 'Default retrieval service id',
retrieveAgentIdHint: 'agent_id of the default retrieval service (kb_search); when unset, every call must name one (kb_service_list discovers ids). Stored in the settings document.',
retrieveAgentIdHintFallback: 'agent_id of the default retrieval service (kb_search); when unset, every call must name one (kb_service_list discovers ids). Leave blank to keep the current one.',
retrieveAgentIdSet: 'A default retrieval service is configured.',
retrieveAgentIdUnset: 'No default retrieval service; every kb_search call must name one.',
chatAgentId: 'Default chat service id',
chatAgentIdHint: 'agent_id of the default Q&A service (kb_chat); when unset, every call must name one (kb_service_list discovers ids). Stored in the settings document.',
chatAgentIdHintFallback: 'agent_id of the default Q&A service (kb_chat); when unset, every call must name one (kb_service_list discovers ids). Leave blank to keep the current one.',
chatAgentIdSet: 'A default chat service is configured.',
chatAgentIdUnset: 'No default chat service; every kb_chat call must name one.',
fromEnv: 'Set by the environment (read-only here)',
clear: 'Clear default',
clearing: 'Clearing…',
expand: 'Show settings',
collapse: 'Hide settings',
save: 'Save',
saving: 'Saving…',
discard: 'Discard',
@@ -44,25 +55,33 @@ export const en: Record<BailianKbLocaleKey, string> = {
/** Simplified Chinese copy. */
export const zh: Record<BailianKbLocaleKey, string> = {
nav: '百炼知识库',
title: '百炼知识库',
description: '知识库工具的账号信息:API 密钥、工作空间与默认服务。',
settingsUnavailable: '当前浏览器无法访问设置文档;以下字段仅可写入凭据存储,不回显。',
fallbackConfigured: '回退:凭据存储中已有值。',
apiKey: 'API 密钥',
apiKeyHint: 'DashScope API key。保存在凭据存储中且不会再次显示;留空表示保持当前值。',
apiKeySet: '已配置密钥。',
apiKeyUnset: '未配置密钥;配置前知识库工具不可用。',
workspaceId: '工作空间 ID',
workspaceIdHint: '百炼工作空间 ID,即终端节点地址的子域名。留空表示保持当前值。',
workspaceIdHint: '百炼工作空间 ID,即终端节点地址的子域名。存入设置文档;清空并保存则回退到凭据存储。',
workspaceIdHintFallback: '百炼工作空间 ID,即终端节点地址的子域名。留空表示保持当前值。',
workspaceIdSet: '已配置工作空间。',
workspaceIdUnset: '未配置工作空间;配置前知识库工具不可用。',
agentId: '默认服务 ID',
agentIdHint: '默认检索/问答服务的 agent_id;未设置时每次调用都需显式指定(可用 kb_service_list 发现 id)。留空表示保持当前值。',
agentIdSet: '已配置默认服务。',
agentIdUnset: '未配置默认服务;每次调用需显式指定。',
retrieveAgentId: '默认检索服务 ID',
retrieveAgentIdHint: '默认检索服务(kb_search)的 agent_id;未设置时每次调用都需显式指定(可用 kb_service_list 发现 id)。存入设置文档。',
retrieveAgentIdHintFallback: '默认检索服务(kb_search)的 agent_id;未设置时每次调用都需显式指定(可用 kb_service_list 发现 id)。留空表示保持当前值。',
retrieveAgentIdSet: '已配置默认检索服务。',
retrieveAgentIdUnset: '未配置默认检索服务;每次 kb_search 调用需显式指定。',
chatAgentId: '默认对话服务 ID',
chatAgentIdHint: '默认对话服务(kb_chat)的 agent_id;未设置时每次调用都需显式指定(可用 kb_service_list 发现 id)。存入设置文档。',
chatAgentIdHintFallback: '默认对话服务(kb_chat)的 agent_id;未设置时每次调用都需显式指定(可用 kb_service_list 发现 id)。留空表示保持当前值。',
chatAgentIdSet: '已配置默认对话服务。',
chatAgentIdUnset: '未配置默认对话服务;每次 kb_chat 调用需显式指定。',
fromEnv: '来自环境变量(此处只读)',
clear: '清除默认',
clearing: '清除中…',
expand: '展开设置',
collapse: '收起设置',
save: '保存',
saving: '保存中…',
discard: '放弃',
@@ -7,7 +7,8 @@ describe('Config', () => {
expect(resolved.workspaceId).toBe('ws-1')
expect(resolved.endpointHost).toBe('cn-beijing.maas.aliyuncs.com')
expect(resolved.chatTimeoutMs).toBe(300_000)
expect(resolved.defaultAgentId).toBeUndefined()
expect(resolved.defaultRetrieveAgentId).toBeUndefined()
expect(resolved.defaultChatAgentId).toBeUndefined()
})
it('accepts a missing workspaceId (per-call credentials fallback)', () => {
+19 -6
View File
@@ -4,9 +4,9 @@ import { createKbTools } from '../src/tools.js'
const EXEC = {} as never
function toolsWith(postJson: unknown, postSse?: unknown, resolveDefaultAgentId?: () => Promise<string | undefined>) {
function toolsWith(postJson: unknown, postSse?: unknown, resolveDefaultRetrieveAgentId?: () => Promise<string | undefined>, resolveDefaultChatAgentId?: () => Promise<string | undefined>) {
const client = { postJson, postSse, agentVersion: undefined } as unknown as KbClient
const list = createKbTools({ client, ...(resolveDefaultAgentId ? { resolveDefaultAgentId } : {}), chatTimeoutMs: 1000 })
const list = createKbTools({ client, ...(resolveDefaultRetrieveAgentId ? { resolveDefaultRetrieveAgentId } : {}), ...(resolveDefaultChatAgentId ? { resolveDefaultChatAgentId } : {}), chatTimeoutMs: 1000 })
const byName = Object.fromEntries(list.map(t => [t.name, t]))
return { byName, list }
}
@@ -48,7 +48,7 @@ describe('createKbTools', () => {
expect(requiredList(withDefault)).not.toContain('agent_id')
})
it('kb_search falls back to the per-call default resolver as an explicit resolve step', async () => {
it('kb_search falls back to the per-call default retrieve resolver as an explicit resolve step', async () => {
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse)
const { byName } = toolsWith(postJson, undefined, async () => 'aid-fixed')
await byName.kb_search!.execute({ query: 'q' }, EXEC)
@@ -65,13 +65,13 @@ describe('createKbTools', () => {
it('kb_search re-resolves the default per call (credential hot-swap contract)', async () => {
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse)
let current: string | undefined
const resolveDefaultAgentId = vi.fn(async () => current)
const { byName } = toolsWith(postJson, undefined, resolveDefaultAgentId)
const resolveDefaultRetrieveAgentId = vi.fn(async () => current)
const { byName } = toolsWith(postJson, undefined, resolveDefaultRetrieveAgentId)
current = 'aid-one'
await byName.kb_search!.execute({ query: 'q' }, EXEC)
current = undefined
await byName.kb_search!.execute({ query: 'q' }, EXEC).catch(() => {})
expect(resolveDefaultAgentId).toHaveBeenCalledTimes(2)
expect(resolveDefaultRetrieveAgentId).toHaveBeenCalledTimes(2)
expect((postJson.mock.calls[0]![1] as Record<string, unknown>).agent_id).toBe('aid-one')
expect(postJson).toHaveBeenCalledTimes(1)
})
@@ -101,4 +101,17 @@ describe('createKbTools', () => {
const err = await byName.kb_chat!.execute({ message: 'q', agent_id: 'aid-1' }, EXEC).catch((e: unknown) => e)
expect((err as Error).message).toMatch(/timed out.*kb_search/s)
})
it('kb_chat reads chatTimeoutMs off deps per call (live-settings getter stays live)', async () => {
const timeout = Object.assign(new Error('operation timed out'), { name: 'TimeoutError' })
const postSse = vi.fn(async () => { throw timeout })
const client = { postJson: vi.fn(), postSse, agentVersion: undefined } as unknown as KbClient
// Mirrors the host apply: a getter over the mutable settings source.
let timeoutMs = 1000
const list = createKbTools({ client, get chatTimeoutMs() { return timeoutMs } })
const chat = list.find(t => t.name === 'kb_chat')!
timeoutMs = 2222
const err = await chat.execute({ message: 'q', agent_id: 'aid-1' }, EXEC).catch((e: unknown) => e)
expect((err as Error).message).toContain('2222ms')
})
})
+6 -6
View File
@@ -38,18 +38,18 @@ importers:
'@deepseek-ai/dsh-client-runtime':
specifier: link:../../../deepseek-harness/packages/client/runtime
version: link:../../../deepseek-harness/packages/client/runtime
'@deepseek-ai/dsh-client-ui-primitives':
specifier: link:../../../deepseek-harness/packages/client/ui-primitives
version: link:../../../deepseek-harness/packages/client/ui-primitives
'@deepseek-ai/dsh-client-ui-settings-plugins':
specifier: link:../../../deepseek-harness/packages/client/ui-settings-plugins
version: link:../../../deepseek-harness/packages/client/ui-settings-plugins
'@deepseek-ai/dsh-client-ui-settings':
specifier: link:../../../deepseek-harness/packages/client/ui-settings
version: link:../../../deepseek-harness/packages/client/ui-settings
'@deepseek-ai/dsh-client-ui-slots':
specifier: link:../../../deepseek-harness/packages/client/ui-slots
version: link:../../../deepseek-harness/packages/client/ui-slots
'@deepseek-ai/dsh-credentials':
specifier: link:../../../deepseek-harness/packages/credentials/credentials
version: link:../../../deepseek-harness/packages/credentials/credentials
'@deepseek-ai/dsh-settings':
specifier: link:../../../deepseek-harness/packages/settings/settings
version: link:../../../deepseek-harness/packages/settings/settings
'@deepseek-ai/dsh-skill':
specifier: link:../../../deepseek-harness/packages/skill/skill
version: link:../../../deepseek-harness/packages/skill/skill