From b11adcc6fe4d034a4f809b2d5a31ad488c433c5e Mon Sep 17 00:00:00 2001 From: "zeyu.fz" Date: Sun, 23 Aug 2026 15:44:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(tool-bailian-kb):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=BC=93=E5=AD=98=E6=9C=BA=E5=88=B6=E5=8F=8A?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=9C=8D=E5=8A=A1=E9=80=89=E6=8B=A9=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加对服务清单缓存的动态刷新与过期时间优化,空缓存采用更短TTL以避免首次配置延迟 - 注册工具执行结果监听,检测到管理命令后立即使服务缓存失效并刷新 - 提供新的HTTP路由支持面板强制刷新和获取服务缓存快照 - 实现服务缓存状态接口,方便面板展示缓存健康状况和服务列表数量 - 在前端增加服务缓存视图,显示缓存状态、最后更新时间及刷新按钮 - 支持默认检索服务与对话服务的选择器,允许清除和从缓存服务列表选择 - 移除原有默认服务ID的文本框,避免与选择器内容重复且不同步 - 更新国际化文本,反映默认服务选择器和服务缓存状态相关内容 - 添加单元测试验证空缓存TTL行为及状态快照正确性 --- packages/tool-bailian-kb/package.json | 2 +- packages/tool-bailian-kb/src/index.ts | 49 ++++++ packages/tool-bailian-kb/src/service-cache.ts | 74 ++++++++- .../tool-bailian-kb/src/web/BailianCard.tsx | 122 ++++++++++++++- .../src/web/bailian-card-controller.ts | 145 ++++++++++++++++++ packages/tool-bailian-kb/src/web/locales.ts | 68 +++++--- .../tests/service-cache.test.ts | 51 ++++++ 7 files changed, 483 insertions(+), 28 deletions(-) diff --git a/packages/tool-bailian-kb/package.json b/packages/tool-bailian-kb/package.json index 9f6591e..935d3d0 100644 --- a/packages/tool-bailian-kb/package.json +++ b/packages/tool-bailian-kb/package.json @@ -1,6 +1,6 @@ { "name": "@ali/bailian-kb-dsh", - "version": "0.1.16", + "version": "0.1.18", "description": "Bailian knowledge-base tools for DeepSeek Harness: kb_search and kb_chat over the DashScope RAG API, plus the bl CLI management skill.", "type": "module", "main": "lib/index.js", diff --git a/packages/tool-bailian-kb/src/index.ts b/packages/tool-bailian-kb/src/index.ts index 12bd27a..cf59a98 100644 --- a/packages/tool-bailian-kb/src/index.ts +++ b/packages/tool-bailian-kb/src/index.ts @@ -324,6 +324,23 @@ export function apply(ctx: Context, config: Config): void { } registerSkill(ctx) + // A management command that changes the service inventory invalidates the + // cache immediately, so the next session sees the new service instead of + // waiting out the TTL. `tools/result` is observe-only (it returns undefined and + // sits after the pipeline), so listening here cannot affect tool execution. + // + // The command string is matched inside the serialized arguments rather than + // against a specific tool name: the agent may run `bl` through bash, a + // terminal tool, or a run_code program. A loose match is deliberate — a false + // positive costs one list request, while a miss falls back to the TTL. + ctx.on('tools/result', (_exec, result) => { + if (result.isError) return + const args = JSON.stringify((_exec as { arguments?: unknown }).arguments ?? '') + if (!/bl\s+knowledge\s+service\s+(create|deploy|delete|copy)/.test(args)) return + serviceCache.invalidate() + void serviceCache.refresh() + }) + // The service catalog rides an `agent/pre-step` context message rather than the // tool descriptions: descriptions freeze at plugin load, and a plugin loads // once per process, so in a long-running host a service created elsewhere @@ -423,6 +440,38 @@ export function apply(ctx: Context, config: Config): void { }, }), 'tool-bailian-kb: settings bridge route') + // Service cache bridge: the panel's only window into cache freshness. + // GET returns the diagnostic snapshot plus the pickable services; POST + // forces a refresh and returns the same shape, so the numbers the developer + // sees update in place. + wctx.effect(() => wctx.webServer.register({ + kind: 'exact', + path: '/bailian-kb/services', + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'POST') { + sendJson(res, 405, { error: 'use GET or POST' }) + return + } + const workspaceId = await resolveWorkspaceIdOrUndefined() + if (workspaceId === undefined) { + sendJson(res, 200, { configured: false }) + return + } + if (req.method === 'POST') { + // Force a fetch regardless of TTL: the button exists precisely for the + // case where the developer believes the cache is wrong. + serviceCache.invalidate() + await serviceCache.refresh() + } + sendJson(res, 200, { + configured: true, + status: serviceCache.status(workspaceId), + search: serviceCache.entriesFor(workspaceId, 'search'), + chat: serviceCache.entriesFor(workspaceId, 'chat'), + }) + }, + }), 'tool-bailian-kb: service cache bridge route') + // Autofill bridge: fetch credentials by signing in to the Bailian console // (panel button). `login` drives the console's callback protocol on the // host and persists what comes back — the plain key never rides the wire diff --git a/packages/tool-bailian-kb/src/service-cache.ts b/packages/tool-bailian-kb/src/service-cache.ts index 1b53c1b..114bb3d 100644 --- a/packages/tool-bailian-kb/src/service-cache.ts +++ b/packages/tool-bailian-kb/src/service-cache.ts @@ -32,6 +32,18 @@ const CACHE_VERSION = 1 /** Refresh interval. Evaluated per `agent/pre-step`, so a short window genuinely takes effect. */ export const CACHE_TTL_MS = 30 * 60 * 1000 +/** + * Refresh interval applied when the cached list is EMPTY. + * + * An empty list is almost never a settled fact — it is the intermediate state of + * a workspace being set up. Caching that negative result for the full TTL breaks + * the standard first-run path: configure the plugin against a fresh workspace (0 + * services) → create a knowledge base and a service → and then wait up to half an + * hour before the catalog appears. Re-asking every minute while the answer is + * "nothing yet" has a bounded cost and removes that trap. + */ +export const EMPTY_CACHE_TTL_MS = 60 * 1000 + /** The stored document. */ export interface ServiceCacheDocument { version: number @@ -109,6 +121,19 @@ export function writeServiceCache(path: string, doc: ServiceCacheDocument): void renameSync(temp, path) } +/** What the settings panel shows about the cache; see {@link ServiceCache.status}. */ +export interface ServiceCacheStatus { + workspaceId: string + /** Epoch millis of the last successful fetch; absent when nothing is cached. */ + fetchedAt?: number + searchCount: number + chatCount: number + /** Server-reported total, which exceeds the counts above when the fetch was capped. */ + total: number + truncated: boolean + stale: boolean +} + export interface ServiceCacheOptions { client: KbClient /** Resolves the current workspace id; a failure means "not configured yet". */ @@ -158,13 +183,16 @@ export class ServiceCache { } /** - * Whether the cached document is missing or older than the TTL. + * Whether the cached document is missing or older than its TTL. + * An empty list expires on the much shorter {@link EMPTY_CACHE_TTL_MS}. * @param workspaceId - the workspace being served. * @returns true when a refresh is due. */ isStale(workspaceId: string): boolean { const doc = this.peek(workspaceId) - return doc === undefined || this.now - doc.fetchedAt >= CACHE_TTL_MS + if (doc === undefined) return true + const ttl = doc.entries.length === 0 ? EMPTY_CACHE_TTL_MS : CACHE_TTL_MS + return this.now - doc.fetchedAt >= ttl } /** Drop the in-memory view and force the next `peek` to re-read from disk. */ @@ -173,6 +201,48 @@ export class ServiceCache { this.loadedFor = undefined } + /** + * A diagnostic snapshot for the settings panel. + * + * The panel exists because this cache's staleness is otherwise invisible: a + * developer whose agent silently stops retrieving cannot tell an empty + * workspace from a stale list without reading the JSON file. `fetchedAt` plus + * the per-scene counts answer that in one glance. + * @param workspaceId - the workspace being served. + * @returns the snapshot; `fetchedAt` is undefined when nothing is cached. + */ + status(workspaceId: string): ServiceCacheStatus { + const doc = this.peek(workspaceId) + if (doc === undefined) { + return { workspaceId, searchCount: 0, chatCount: 0, total: 0, truncated: false, stale: true } + } + return { + workspaceId, + fetchedAt: doc.fetchedAt, + searchCount: doc.entries.filter(entry => entry.scene === 'search').length, + chatCount: doc.entries.filter(entry => entry.scene === 'chat').length, + total: doc.total, + truncated: doc.truncated, + stale: this.isStale(workspaceId), + } + } + + /** + * The cached entries of one scene, most recently modified first. + * Backs the panel's service picker, which exists so a default service can be + * chosen by name instead of by pasting a 36-character hex id. + * @param workspaceId - the workspace being served. + * @param scene - `search` or `chat`. + * @returns the entries, newest first. + */ + entriesFor(workspaceId: string, scene: ServiceEntry['scene']): ServiceEntry[] { + const doc = this.peek(workspaceId) + if (doc === undefined) return [] + return doc.entries + .filter(entry => entry.scene === scene) + .sort((left, right) => (right.modify_time ?? '').localeCompare(left.modify_time ?? '')) + } + /** * Fetch and store the current service list. * Never rejects: failures are warned and leave the previous document in place. diff --git a/packages/tool-bailian-kb/src/web/BailianCard.tsx b/packages/tool-bailian-kb/src/web/BailianCard.tsx index 8b87562..d29b2e3 100644 --- a/packages/tool-bailian-kb/src/web/BailianCard.tsx +++ b/packages/tool-bailian-kb/src/web/BailianCard.tsx @@ -46,12 +46,17 @@ interface FieldView { getUrl?: string } -/** The controls, in page order. */ +/** + * The controls, in page order. + * + * The two default-service ids are NOT here: they render as pickers inside the + * advanced section instead, driven by the cached service list. A free-text id + * field beside a picker for the same setting is the same value twice, and the + * pair drifts the moment one of them writes. + */ const FIELDS: readonly FieldView[] = [ { key: 'DASHSCOPE_API_KEY', labelKey: 'apiKey', getKey: 'apiKeyGet', hintKey: 'apiKeyHint', fallbackHintKey: 'apiKeyHint', setKey: 'apiKeySet', unsetKey: 'apiKeyUnset', secret: true, advanced: true, getUrl: BAILIAN_CONSOLE_API_KEY_URL }, { key: 'BAILIAN_WORKSPACE_ID', labelKey: 'workspaceId', getKey: 'workspaceIdGet', hintKey: 'workspaceIdHint', fallbackHintKey: 'workspaceIdHintFallback', setKey: 'workspaceIdSet', unsetKey: 'workspaceIdUnset', secret: false, advanced: true, getUrl: BAILIAN_CONSOLE_API_KEY_URL }, - { key: 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID', labelKey: 'retrieveAgentId', hintKey: 'retrieveAgentIdHint', fallbackHintKey: 'retrieveAgentIdHintFallback', setKey: 'retrieveAgentIdSet', unsetKey: 'retrieveAgentIdUnset', secret: false, advanced: true }, - { key: 'BAILIAN_DEFAULT_CHAT_AGENT_ID', labelKey: 'chatAgentId', hintKey: 'chatAgentIdHint', fallbackHintKey: 'chatAgentIdHintFallback', setKey: 'chatAgentIdSet', unsetKey: 'chatAgentIdUnset', secret: false, advanced: true }, ] const ADVANCED_FIELDS = FIELDS.filter(field => field.advanced) @@ -167,6 +172,114 @@ export function BailianCard(props: BailianCardProps) { ) } + /** + * One scene's default-service picker — the sole control for that setting. + * + * The cached list is the menu, but a value already pinned outside this list + * (the fetch is capped, so an older service can be absent) is prepended as its + * own option: dropping it would make the panel silently forget a live setting. + */ + function renderPicker(scene: 'search' | 'chat') { + const cache = state.cache + const entries = scene === 'search' ? cache.search : cache.chat + const pinned = scene === 'search' + ? state.settings.values.defaultRetrieveAgentId + : state.settings.values.defaultChatAgentId + const isPinned = pinned !== undefined && pinned !== '' + const pinnedIsListed = isPinned && entries.some(entry => entry.agent_id === pinned) + return ( +
+
+ {t(scene === 'search' ? 'retrieveAgentId' : 'chatAgentId')} + {isPinned + ? ( + + ) + : null} +
+ +

+ {entries.length === 0 ? t('cacheEmpty') : t(scene === 'search' ? 'retrieveAgentIdHint' : 'chatAgentIdHint')} +

+
+ ) + } + + /** + * The service-cache diagnostics: last fetch, per-scene counts, refresh. + * + * This stays OUTSIDE the advanced fold on purpose. It is the answer to "why did + * the agent stop retrieving" — an empty workspace and a stale list look + * identical from the outside, and before this the only way to tell them apart + * was reading the cache JSON off disk. + */ + function renderCacheStatus() { + const cache = state.cache + if (cache.status === 'loading') return

{t('cacheLoading')}

+ if (cache.status === 'unconfigured') return

{t('cacheUnconfigured')}

+ if (cache.status === 'unavailable') return

{t('cacheUnavailable')}

+ + const fetched = cache.fetchedAt === undefined + ? t('cacheNever') + : new Date(cache.fetchedAt).toLocaleString() + + return ( +
+
+ {t('cacheTitle')} + +
+

+ {t('cacheFetchedAt')}: {fetched} + {cache.stale ? ` (${t('cacheStale')})` : ''} + {' · '} + {t('cacheSearchCount')}: {cache.searchCount} + {' · '} + {t('cacheChatCount')}: {cache.chatCount} +

+ {cache.truncated ?

{t('cacheTruncated')}

: null} + {cache.searchCount === 0 && cache.chatCount === 0 + ?

{t('cacheEmpty')}

+ : null} +

{t('cacheHint')}

+
+ ) + } + return (
@@ -198,6 +311,7 @@ export function BailianCard(props: BailianCardProps) { {state.settings.status === 'unavailable' ?

{t('settingsUnavailable')}

: null} + {renderCacheStatus()}