feat(tool-bailian-kb): 增强服务缓存机制及默认服务选择功能

- 添加对服务清单缓存的动态刷新与过期时间优化,空缓存采用更短TTL以避免首次配置延迟
- 注册工具执行结果监听,检测到管理命令后立即使服务缓存失效并刷新
- 提供新的HTTP路由支持面板强制刷新和获取服务缓存快照
- 实现服务缓存状态接口,方便面板展示缓存健康状况和服务列表数量
- 在前端增加服务缓存视图,显示缓存状态、最后更新时间及刷新按钮
- 支持默认检索服务与对话服务的选择器,允许清除和从缓存服务列表选择
- 移除原有默认服务ID的文本框,避免与选择器内容重复且不同步
- 更新国际化文本,反映默认服务选择器和服务缓存状态相关内容
- 添加单元测试验证空缓存TTL行为及状态快照正确性
This commit is contained in:
zeyu.fz
2026-08-23 15:44:52 +08:00
parent 90dd038a2f
commit b11adcc6fe
7 changed files with 483 additions and 28 deletions
+1 -1
View File
@@ -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",
+49
View File
@@ -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
+72 -2
View File
@@ -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.
@@ -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 (
<div className={css.field}>
<div className={css.head}>
<span className={css.label}>{t(scene === 'search' ? 'retrieveAgentId' : 'chatAgentId')}</span>
{isPinned
? (
<button
type="button"
className={css.clear}
disabled={busy}
onClick={() => { void props.selectDefaultAgent(scene, undefined) }}
>
{t(state.clearing ? 'clearing' : 'pickerClear')}
</button>
)
: null}
</div>
<select
className={css.input}
value={pinned ?? ''}
disabled={busy || cache.status !== 'ready'}
onChange={(event) => {
const next = event.target.value
// The empty option is the clear path, which must remove the value
// from both the settings layer and the credential store.
void props.selectDefaultAgent(scene, next === '' ? undefined : next)
}}
>
<option value="">{t('pickerNone')}</option>
{isPinned && !pinnedIsListed
? <option value={pinned}>{pinned}</option>
: null}
{entries.map(entry => (
<option key={entry.agent_id} value={entry.agent_id}>
{entry.agent_name === '' ? entry.agent_id : entry.agent_name}
</option>
))}
</select>
<p className={css.hint}>
{entries.length === 0 ? t('cacheEmpty') : t(scene === 'search' ? 'retrieveAgentIdHint' : 'chatAgentIdHint')}
</p>
</div>
)
}
/**
* 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 <p className={css.hint}>{t('cacheLoading')}</p>
if (cache.status === 'unconfigured') return <p className={css.notice}>{t('cacheUnconfigured')}</p>
if (cache.status === 'unavailable') return <p className={css.notice}>{t('cacheUnavailable')}</p>
const fetched = cache.fetchedAt === undefined
? t('cacheNever')
: new Date(cache.fetchedAt).toLocaleString()
return (
<div className={css.field}>
<div className={css.head}>
<span className={css.label}>{t('cacheTitle')}</span>
<button
type="button"
className={css.clear}
disabled={cache.refreshing}
onClick={() => { void props.refreshServices() }}
>
{t(cache.refreshing ? 'cacheRefreshing' : 'cacheRefresh')}
</button>
</div>
<p className={css.hint}>
{t('cacheFetchedAt')}: {fetched}
{cache.stale ? ` (${t('cacheStale')})` : ''}
{' · '}
{t('cacheSearchCount')}: {cache.searchCount}
{' · '}
{t('cacheChatCount')}: {cache.chatCount}
</p>
{cache.truncated ? <p className={css.notice}>{t('cacheTruncated')}</p> : null}
{cache.searchCount === 0 && cache.chatCount === 0
? <p className={css.notice}>{t('cacheEmpty')}</p>
: null}
<p className={css.hint}>{t('cacheHint')}</p>
</div>
)
}
return (
<section className={css.section}>
<div className={css.headRow}>
@@ -198,6 +311,7 @@ export function BailianCard(props: BailianCardProps) {
{state.settings.status === 'unavailable'
? <p className={css.notice}>{t('settingsUnavailable')}</p>
: null}
{renderCacheStatus()}
<div className={css.advancedSection}>
<button
type="button"
@@ -211,6 +325,8 @@ export function BailianCard(props: BailianCardProps) {
? (
<div className={css.advancedFields}>
{ADVANCED_FIELDS.map(renderField)}
{renderPicker('search')}
{renderPicker('chat')}
<div className={css.footer}>
{state.failed ? <p className={css.failed} role="status">{t('saveFailed')}</p> : null}
<button
@@ -64,6 +64,40 @@ export interface BailianSettingsView {
/** Where the autofill flow (adopt a Bailian console login) currently stands. */
export type BailianAutofillStatus = 'idle' | 'running' | 'awaitingLogin' | 'done' | 'failed'
/** One cached retrieval or Q&A service, as the picker lists it. */
export interface BailianServiceEntry {
agent_id: string
agent_name: string
scene: 'search' | 'chat'
status: string
modify_time?: string
}
/**
* The service cache as the panel shows it.
*
* This exists because cache staleness is otherwise invisible: an agent that
* silently stops retrieving looks identical whether the workspace is empty or the
* list is merely out of date. `fetchedAt` with the per-scene counts distinguishes
* those two in one glance, which is the whole reason the panel earns its space.
*/
export interface BailianCacheView {
/** `unconfigured` = no workspace id yet; `unavailable` = the bridge route failed. */
status: 'loading' | 'ready' | 'unconfigured' | 'unavailable'
/** 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 when the fetch was capped. */
total: number
truncated: boolean
stale: boolean
search: BailianServiceEntry[]
chat: BailianServiceEntry[]
/** Whether a forced refresh is in flight. */
refreshing: boolean
}
/** What the Bailian page renders. */
export interface BailianCardState {
/** Staged drafts; undefined = untouched (the control shows the echoed value). */
@@ -82,6 +116,8 @@ export interface BailianCardState {
autofill: BailianAutofillStatus
/** Console login URL while `awaitingLogin`, shown in case the host could not open a browser. */
autofillLoginUrl?: string
/** Service cache diagnostics and the pickable services. */
cache: BailianCacheView
}
/** The registration-side face the page's slot entry injects. */
@@ -100,6 +136,14 @@ export interface BailianCardFace {
clearDefaultAgent: (key: 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID' | 'BAILIAN_DEFAULT_CHAT_AGENT_ID') => Promise<void>
/** Adopt a Bailian console login (api key + workspace id) via the Host. */
autofill: () => Promise<void>
/** Force a service-cache refresh, bypassing the TTL. */
refreshServices: () => Promise<void>
/**
* Pin one scene's default service, or clear it when `agentId` is undefined.
* Clearing removes the value from the settings user layer AND the credential
* store, so the fallback chain cannot resurrect what the user just cleared.
*/
selectDefaultAgent: (scene: 'search' | 'chat', agentId: string | undefined) => Promise<void>
}
/** The text a field's control shows when its draft is untouched. */
@@ -155,8 +199,20 @@ export class BailianCardController {
failed: false,
autofill: 'idle',
autofillLoginUrl: undefined,
cache: {
status: 'loading',
searchCount: 0,
chatCount: 0,
total: 0,
truncated: false,
stale: true,
search: [],
chat: [],
refreshing: false,
},
})
void this.fetchSettings()
void this.fetchServices()
void this.read()
}
@@ -187,6 +243,93 @@ export class BailianCardController {
}
}
/**
* Read the service cache snapshot from the Host bridge route.
* @param force - POST instead of GET, making the Host refetch regardless of TTL.
*/
async fetchServices(force = false): Promise<void> {
if (force) this.store.update(draft => { draft.cache.refreshing = true })
try {
const resp = await fetch('/bailian-kb/services', { method: force ? 'POST' : 'GET' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const value = await resp.json() as {
configured?: boolean
status?: {
fetchedAt?: number
searchCount?: number
chatCount?: number
total?: number
truncated?: boolean
stale?: boolean
}
search?: BailianServiceEntry[]
chat?: BailianServiceEntry[]
}
this.store.update(draft => {
draft.cache.refreshing = false
if (value.configured !== true) {
// No workspace id yet: the panel says so rather than showing zeros,
// which would read as "the workspace has no services".
draft.cache.status = 'unconfigured'
return
}
draft.cache.status = 'ready'
draft.cache.fetchedAt = value.status?.fetchedAt
draft.cache.searchCount = value.status?.searchCount ?? 0
draft.cache.chatCount = value.status?.chatCount ?? 0
draft.cache.total = value.status?.total ?? 0
draft.cache.truncated = value.status?.truncated === true
draft.cache.stale = value.status?.stale === true
draft.cache.search = value.search ?? []
draft.cache.chat = value.chat ?? []
})
} catch (_routeFailure) {
this.store.update(draft => {
draft.cache.refreshing = false
draft.cache.status = 'unavailable'
})
}
}
/** Force a refresh, bypassing the TTL, and show the updated numbers. */
async refreshServices(): Promise<void> {
if (this.store.getSnapshot().cache.refreshing) return
await this.fetchServices(true)
}
/**
* Pin or clear one scene's default service.
*
* Clearing delegates to {@link clearDefaultAgent}, which removes the value from
* the settings user layer AND the credential store — without the second
* removal the fallback chain would resurrect what the user just cleared.
* @param scene - which tool's default to set.
* @param agentId - the service id to pin, or undefined to clear.
*/
async selectDefaultAgent(scene: 'search' | 'chat', agentId: string | undefined): Promise<void> {
const key = scene === 'search' ? 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID' : 'BAILIAN_DEFAULT_CHAT_AGENT_ID'
if (agentId === undefined) {
await this.clearDefaultAgent(key)
return
}
const field: BailianSettingsField = scene === 'search' ? 'defaultRetrieveAgentId' : 'defaultChatAgentId'
this.store.update(draft => { draft.saving = true })
try {
const resp = await fetch('/bailian-kb/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: agentId }),
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
this.store.update(draft => { draft.failed = false })
} catch (_writeFailure) {
this.store.update(draft => { draft.failed = true })
} finally {
this.store.update(draft => { draft.saving = false })
await this.fetchSettings()
}
}
/**
* Stage one draft; any edit clears the failure mark so the banner does not
* outlive the correction it asks for.
@@ -409,6 +552,8 @@ export class BailianCardController {
discard: () => { this.discard() },
clearDefaultAgent: (key) => this.clearDefaultAgent(key),
autofill: () => this.autofill(),
refreshServices: () => this.refreshServices(),
selectDefaultAgent: (scene, agentId) => this.selectDefaultAgent(scene, agentId),
}
}
+46 -22
View File
@@ -12,13 +12,17 @@ export type BailianKbLocaleKey =
| 'nav' | 'title' | 'description' | 'settingsUnavailable' | 'fallbackConfigured'
| 'apiKey' | 'apiKeyHint' | 'apiKeySet' | 'apiKeyUnset' | 'apiKeyGet'
| 'workspaceId' | 'workspaceIdHint' | 'workspaceIdHintFallback' | 'workspaceIdSet' | 'workspaceIdUnset' | 'workspaceIdGet'
| 'retrieveAgentId' | 'retrieveAgentIdHint' | 'retrieveAgentIdHintFallback' | 'retrieveAgentIdSet' | 'retrieveAgentIdUnset'
| 'chatAgentId' | 'chatAgentIdHint' | 'chatAgentIdHintFallback' | 'chatAgentIdSet' | 'chatAgentIdUnset'
| 'retrieveAgentId' | 'retrieveAgentIdHint'
| 'chatAgentId' | 'chatAgentIdHint'
| 'fromEnv' | 'clear' | 'clearing'
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed'
| 'advancedConfig'
| 'autofill' | 'autofilling' | 'autofillHint'
| 'autofillDone' | 'autofillAwaitingLogin' | 'autofillOpenUrl' | 'autofillFailed' | 'autofillConfigured'
| 'cacheTitle' | 'cacheHint' | 'cacheLoading' | 'cacheUnconfigured' | 'cacheUnavailable'
| 'cacheFetchedAt' | 'cacheNever' | 'cacheStale' | 'cacheSearchCount' | 'cacheChatCount'
| 'cacheTruncated' | 'cacheEmpty' | 'cacheRefresh' | 'cacheRefreshing'
| 'pickerNone' | 'pickerClear'
/** English copy. */
export const en: Record<BailianKbLocaleKey, string> = {
@@ -38,16 +42,10 @@ export const en: Record<BailianKbLocaleKey, string> = {
workspaceIdSet: 'A workspace is configured.',
workspaceIdUnset: 'No workspace is configured; knowledge tools fail until one is.',
workspaceIdGet: 'Get',
retrieveAgentId: 'Default retrieval service id',
retrieveAgentIdHint: 'agent_id of the default retrieval service (kb_search); when unset, every call must name one (`bl knowledge 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 (`bl knowledge 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 (`bl knowledge 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 (`bl knowledge 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.',
retrieveAgentId: 'Default retrieval service',
retrieveAgentIdHint: 'The agent_id kb_search falls back to. Left unset, the injected catalog lists every deployed service instead.',
chatAgentId: 'Default Q&A service',
chatAgentIdHint: 'The agent_id kb_chat falls back to. Left unset, the injected catalog lists every deployed service instead.',
fromEnv: 'Set by the environment (read-only here)',
clear: 'Clear default',
clearing: 'Clearing…',
@@ -65,6 +63,22 @@ export const en: Record<BailianKbLocaleKey, string> = {
autofillOpenUrl: 'Open the login page manually',
autofillFailed: 'Auto-fill failed — the credential may be locked by an environment variable, the Host refused the write, or the login was abandoned.',
autofillConfigured: 'Configured. Click button to fetch this account’s API key and workspace id again.',
cacheTitle: 'Retrieval service cache',
cacheHint: 'The service list injected into each conversation. Refreshes on its own; refresh here when you have just created a service and want it picked up now.',
cacheLoading: 'Reading…',
cacheUnconfigured: 'Set a workspace id first.',
cacheUnavailable: 'Not reachable from this browser.',
cacheFetchedAt: 'Last fetched',
cacheNever: 'never',
cacheStale: 'refresh due',
cacheSearchCount: 'Retrieval services',
cacheChatCount: 'Q&A services',
cacheTruncated: 'List truncated — the workspace holds more than were fetched.',
cacheEmpty: 'No deployed services cached. If you just created one, refresh.',
cacheRefresh: 'Refresh',
cacheRefreshing: 'Refreshing…',
pickerNone: 'Not set — the full list is injected instead',
pickerClear: 'Clear',
}
/** Simplified Chinese copy. */
@@ -85,16 +99,10 @@ export const zh: Record<BailianKbLocaleKey, string> = {
workspaceIdSet: '已配置工作空间。',
workspaceIdUnset: '未配置工作空间;配置前知识库工具不可用。',
workspaceIdGet: '去获取',
retrieveAgentId: '默认检索服务 ID',
retrieveAgentIdHint: '默认检索服务(kb_search)的 agent_id;未设置时每次调用都需显式指定(可用 `bl knowledge service list` 发现 id)。存入设置文档。',
retrieveAgentIdHintFallback: '默认检索服务(kb_search)的 agent_id;未设置时每次调用都需显式指定(可用 `bl knowledge service list` 发现 id)。留空表示保持当前值。',
retrieveAgentIdSet: '已配置默认检索服务。',
retrieveAgentIdUnset: '未配置默认检索服务;每次 kb_search 调用需显式指定。',
chatAgentId: '默认对话服务 ID',
chatAgentIdHint: '默认对话服务(kb_chat)的 agent_id;未设置时每次调用都需显式指定(可用 `bl knowledge service list` 发现 id)。存入设置文档。',
chatAgentIdHintFallback: '默认对话服务(kb_chat)的 agent_id;未设置时每次调用都需显式指定(可用 `bl knowledge service list` 发现 id)。留空表示保持当前值。',
chatAgentIdSet: '已配置默认对话服务。',
chatAgentIdUnset: '未配置默认对话服务;每次 kb_chat 调用需显式指定。',
retrieveAgentId: '默认检索服务',
retrieveAgentIdHint: 'kb_search 缺省使用的 agent_id。不设置时,注入的清单会列出全部已部署服务。',
chatAgentId: '默认对话服务',
chatAgentIdHint: 'kb_chat 缺省使用的 agent_id。不设置时,注入的清单会列出全部已部署服务。',
fromEnv: '来自环境变量(此处只读)',
clear: '清除默认',
clearing: '清除中…',
@@ -112,4 +120,20 @@ export const zh: Record<BailianKbLocaleKey, string> = {
autofillOpenUrl: '手动打开登录页',
autofillFailed: '自动获取失败——凭据可能被环境变量锁定、宿主拒绝了写入,或登录未完成。',
autofillConfigured: '已配置完成,点击按钮重新获取该账号的 API 密钥与工作空间 ID。',
cacheTitle: '检索服务缓存',
cacheHint: '注入到每次对话的服务清单。会自动刷新;刚建完服务想立即生效时在这里刷一下。',
cacheLoading: '读取中…',
cacheUnconfigured: '请先设置工作空间 ID。',
cacheUnavailable: '当前浏览器无法访问。',
cacheFetchedAt: '上次拉取',
cacheNever: '尚未拉取',
cacheStale: '待刷新',
cacheSearchCount: '检索服务',
cacheChatCount: '问答服务',
cacheTruncated: '清单已截断 —— 工作空间里的服务多于已拉取的数量。',
cacheEmpty: '缓存里没有已部署的服务。如果刚创建过,请刷新。',
cacheRefresh: '刷新',
cacheRefreshing: '刷新中…',
pickerNone: '未设置 —— 会注入完整清单',
pickerClear: '清空',
}
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { KbClient } from '../src/client.js'
import {
CACHE_TTL_MS,
EMPTY_CACHE_TTL_MS,
ServiceCache,
readServiceCache,
serviceCachePath,
@@ -132,6 +133,56 @@ describe('ServiceCache', () => {
expect(cache.isStale('llm-a')).toBe(true)
})
it('expires an EMPTY list on the much shorter negative TTL', () => {
// The trap this closes: configure the plugin against a fresh workspace (0
// services) → create a service → and then wait out the full TTL before the
// catalog appears. An empty list is a setup-in-progress state, not a fact.
const home = tempHome()
writeServiceCache(serviceCachePath('llm-a', home), doc({ fetchedAt: 0, entries: [], total: 0 }))
const justAfterEmptyTtl = cacheWith(home, async () => emptyPage, () => EMPTY_CACHE_TTL_MS)
expect(justAfterEmptyTtl.cache.isStale('llm-a')).toBe(true)
// A non-empty list of the same age is still fresh, so the short window costs
// nothing once services exist.
writeServiceCache(serviceCachePath('llm-b', home), doc({ workspaceId: 'llm-b', fetchedAt: 0 }))
const nonEmpty = cacheWith(home, async () => emptyPage, () => EMPTY_CACHE_TTL_MS)
expect(nonEmpty.cache.isStale('llm-b')).toBe(false)
expect(EMPTY_CACHE_TTL_MS).toBeLessThan(CACHE_TTL_MS)
})
it('reports a panel snapshot that separates "empty workspace" from "stale list"', () => {
const home = tempHome()
const { cache } = cacheWith(home, async () => emptyPage, () => 5_000)
// Nothing cached at all: the panel must be able to say "never fetched"
// rather than showing a zero that reads as "the workspace is empty".
const empty = cache.status('llm-a')
expect(empty).not.toHaveProperty('fetchedAt')
expect(empty).toMatchObject({ searchCount: 0, chatCount: 0, stale: true })
writeServiceCache(serviceCachePath('llm-a', home), doc({
fetchedAt: 5_000,
entries: [
{ agent_id: 'aid-1', agent_name: 'a', scene: 'search', status: 'deployed', modify_time: '2026-08-01' },
{ agent_id: 'aid-2', agent_name: 'b', scene: 'search', status: 'deployed', modify_time: '2026-08-09' },
{ agent_id: 'aid-3', agent_name: 'c', scene: 'chat', status: 'deployed' },
],
total: 900,
truncated: true,
}))
cache.invalidate()
expect(cache.status('llm-a')).toMatchObject({
fetchedAt: 5_000,
searchCount: 2,
chatCount: 1,
total: 900,
truncated: true,
stale: false,
})
// The picker lists newest first so the likely-in-use service is on top.
expect(cache.entriesFor('llm-a', 'search').map(e => e.agent_id)).toEqual(['aid-2', 'aid-1'])
expect(cache.entriesFor('llm-a', 'chat').map(e => e.agent_id)).toEqual(['aid-3'])
})
it('re-reads from disk when the workspace changes', () => {
const home = tempHome()
writeServiceCache(serviceCachePath('llm-a', home), doc({ fetchedAt: 5_000 }))