diff --git a/packages/bundle/README.md b/packages/bundle/README.md index c53b02a..c5476c4 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -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 ``` diff --git a/packages/bundle/cordis.patch.yml b/packages/bundle/cordis.patch.yml index 3cf4d60..5fbdd41 100644 --- a/packages/bundle/cordis.patch.yml +++ b/packages/bundle/cordis.patch.yml @@ -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 diff --git a/packages/tool-bailian-kb/README.md b/packages/tool-bailian-kb/README.md index 00caa1e..c64ad17 100644 --- a/packages/tool-bailian-kb/README.md +++ b/packages/tool-bailian-kb/README.md @@ -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` | string? | — | 百炼工作空间 id;API host 为 workspace 子域名 `https://.`。未设置时每次调用回退 `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 | ## 错误语义 diff --git a/packages/tool-bailian-kb/package.json b/packages/tool-bailian-kb/package.json index 32a1e26..433aead 100644 --- a/packages/tool-bailian-kb/package.json +++ b/packages/tool-bailian-kb/package.json @@ -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", diff --git a/packages/tool-bailian-kb/src/index.ts b/packages/tool-bailian-kb/src/index.ts index 4c41d47..9576ddf 100644 --- a/packages/tool-bailian-kb/src/index.ts +++ b/packages/tool-bailian-kb/src/index.ts @@ -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): Promise { + try { + const seeds: Partial> = {} + 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://.`. 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://.`. 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 = 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 + 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) } diff --git a/packages/tool-bailian-kb/src/tools.ts b/packages/tool-bailian-kb/src/tools.ts index 7a3253c..0ecceee 100644 --- a/packages/tool-bailian-kb/src/tools.ts +++ b/packages/tool-bailian-kb/src/tools.ts @@ -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 + /** Resolves the default retrieval agent id per call (settings/patch config or credential); omitted means no default for kb_search. */ + resolveDefaultRetrieveAgentId?: () => Promise + /** Resolves the default chat agent id per call (settings/patch config or credential); omitted means no default for kb_chat. */ + resolveDefaultChatAgentId?: () => Promise + /** Read per call (a live-settings deployment supplies a getter). */ chatTimeoutMs: number } @@ -53,16 +56,26 @@ async function withServiceHint(client: KbClient, err: unknown): Promise { * @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 => { + const resolveRetrieveAgentId = async (supplied: string | undefined): Promise => { 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 => { + 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, diff --git a/packages/tool-bailian-kb/src/web/BailianCard.module.css b/packages/tool-bailian-kb/src/web/BailianCard.module.css index f9800a8..73f181d 100644 --- a/packages/tool-bailian-kb/src/web/BailianCard.module.css +++ b/packages/tool-bailian-kb/src/web/BailianCard.module.css @@ -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); } diff --git a/packages/tool-bailian-kb/src/web/BailianCard.tsx b/packages/tool-bailian-kb/src/web/BailianCard.tsx index 98dac71..4a4f42e 100644 --- a/packages/tool-bailian-kb/src/web/BailianCard.tsx +++ b/packages/tool-bailian-kb/src/web/BailianCard.tsx @@ -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 @@ -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 ( -
  • - - {open - ? ( -
    - {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 ( -
    -
    - - - {showClear - ? ( - - ) - : null} - {stateLabel} - -
    - { props.edit(field.key, event.target.value) }} - /> -

    {t(field.hintKey)}

    -
    - ) - })} -
    - {state.failed ?

    {t('saveFailed')}

    : null} - - +
    +

    {t('description')}

    +
    + {state.settings.status === 'unavailable' + ?

    {t('settingsUnavailable')}

    + : 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 ( +
    +
    + + + {showClear + ? ( + + ) + : null} + {badge !== undefined + ? {badge.label} + : null} + +
    + { props.edit(field.key, event.target.value) }} + /> +

    {t(echo ? field.hintKey : field.fallbackHintKey)}

    -
    - ) - : null} -
  • + ) + })} +
    + {state.failed ?

    {t('saveFailed')}

    : null} + + +
    + + ) } diff --git a/packages/tool-bailian-kb/src/web/bailian-card-controller.ts b/packages/tool-bailian-kb/src/web/bailian-card-controller.ts index 0965acb..a7f4df8 100644 --- a/packages/tool-bailian-kb/src/web/bailian-card-controller.ts +++ b/packages/tool-bailian-kb/src/web/bailian-card-controller.ts @@ -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> = { + 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 + /** Staged drafts; undefined = untouched (the control shows the echoed value). */ + drafts: Record /** Last credentials-domain answer per reference; unknown refs read as writable. */ credentials: Record + /** 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 } /** 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 /** Drop every staged draft. */ discard: () => void - /** Remove the stored default service (`credentials.unset`), then re-read. */ - clearDefaultAgent: () => Promise + /** 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 } -/** 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 /** * @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) { + constructor( + private readonly api: Pick, + private readonly scope: SettingsScope, + ) { this.store = createSnapshotStore({ 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 { - 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[] = [] + 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 { - 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 { + 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 { @@ -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, diff --git a/packages/tool-bailian-kb/src/web/index.ts b/packages/tool-bailian-kb/src/web/index.ts index ec752ba..17f94c7 100644 --- a/packages/tool-bailian-kb/src/web/index.ts +++ b/packages/tool-bailian-kb/src/web/index.ts @@ -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({ 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)) diff --git a/packages/tool-bailian-kb/src/web/locales.ts b/packages/tool-bailian-kb/src/web/locales.ts index f4b7cf7..1bc9d92 100644 --- a/packages/tool-bailian-kb/src/web/locales.ts +++ b/packages/tool-bailian-kb/src/web/locales.ts @@ -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 = { + 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 = { /** Simplified Chinese copy. */ export const zh: Record = { + 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: '放弃', diff --git a/packages/tool-bailian-kb/tests/config.test.ts b/packages/tool-bailian-kb/tests/config.test.ts index 0030c00..eb66c27 100644 --- a/packages/tool-bailian-kb/tests/config.test.ts +++ b/packages/tool-bailian-kb/tests/config.test.ts @@ -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)', () => { diff --git a/packages/tool-bailian-kb/tests/tools.test.ts b/packages/tool-bailian-kb/tests/tools.test.ts index 14f9632..0404c6a 100644 --- a/packages/tool-bailian-kb/tests/tools.test.ts +++ b/packages/tool-bailian-kb/tests/tools.test.ts @@ -4,9 +4,9 @@ import { createKbTools } from '../src/tools.js' const EXEC = {} as never -function toolsWith(postJson: unknown, postSse?: unknown, resolveDefaultAgentId?: () => Promise) { +function toolsWith(postJson: unknown, postSse?: unknown, resolveDefaultRetrieveAgentId?: () => Promise, resolveDefaultChatAgentId?: () => Promise) { 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).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') + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ef2047..c5904e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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