mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(tool-bailian-kb): 增强服务缓存机制及默认服务选择功能
- 添加对服务清单缓存的动态刷新与过期时间优化,空缓存采用更短TTL以避免首次配置延迟 - 注册工具执行结果监听,检测到管理命令后立即使服务缓存失效并刷新 - 提供新的HTTP路由支持面板强制刷新和获取服务缓存快照 - 实现服务缓存状态接口,方便面板展示缓存健康状况和服务列表数量 - 在前端增加服务缓存视图,显示缓存状态、最后更新时间及刷新按钮 - 支持默认检索服务与对话服务的选择器,允许清除和从缓存服务列表选择 - 移除原有默认服务ID的文本框,避免与选择器内容重复且不同步 - 更新国际化文本,反映默认服务选择器和服务缓存状态相关内容 - 添加单元测试验证空缓存TTL行为及状态快照正确性
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
Reference in New Issue
Block a user