diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile index 2898c2ae..328f5494 100644 --- a/mateclaw-server/Dockerfile +++ b/mateclaw-server/Dockerfile @@ -47,6 +47,7 @@ COPY pom.xml ./pom.xml COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml COPY mateclaw-server/pom.xml mateclaw-server/pom.xml COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml +COPY mateclaw-plugin-search-sample/pom.xml mateclaw-plugin-search-sample/pom.xml # Pre-fetch backend dependencies through the reactor so the parent POM, # dependencyManagement, and internal module versions all resolve consistently. diff --git a/mateclaw-ui/src/components/common/AgentPickerDialog.vue b/mateclaw-ui/src/components/common/AgentPickerDialog.vue index 63523a04..6ae61b7a 100644 --- a/mateclaw-ui/src/components/common/AgentPickerDialog.vue +++ b/mateclaw-ui/src/components/common/AgentPickerDialog.vue @@ -204,12 +204,18 @@ const selectedAgent = computed(() => { }) /** modelValue is set but resolves to no known agent — the referenced - * employee was likely renamed or removed. */ -const isUnknown = computed(() => hasValue.value && !selectedAgent.value) + * employee was likely renamed or removed. + * Requires agents to have loaded first: while the list is still empty + * (component just rebuilt, /agents in flight) we must NOT treat a missing + * match as "unknown" — otherwise the trigger flashes the raw numeric id + * until the list lands. Falling through to the placeholder during that + * window keeps the trigger readable and self-heals once agents arrive. */ +const isUnknown = computed(() => + hasValue.value && !selectedAgent.value && props.agents.length > 0) const triggerLabel = computed(() => { if (selectedAgent.value) return selectedAgent.value.name - if (isUnknown.value) return props.unknownLabel || String(props.modelValue) + if (isUnknown.value) return props.unknownLabel || t('agentContext.unknownAgent') return props.placeholder || t('agentContext.selectAgent') }) diff --git a/mateclaw-ui/src/components/common/__tests__/agentPickerLogic.test.ts b/mateclaw-ui/src/components/common/__tests__/agentPickerLogic.test.ts new file mode 100644 index 00000000..231569ac --- /dev/null +++ b/mateclaw-ui/src/components/common/__tests__/agentPickerLogic.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import type { PickableAgent } from '../AgentPickerDialog.vue' + +/** + * 提取 AgentPickerDialog 中 isUnknown / triggerLabel 的核心判定逻辑做纯函数测试。 + * 这些 computed 的判定决定了触发器在「列表未加载」「员工被删除」等边界下显示什么, + * 历史上因为缺少 agents.length > 0 的守卫,组件重建瞬间会闪现原始数字 ID(Bug 1)。 + */ + +const AGENTS: PickableAgent[] = [ + { id: 1000000001, name: '通用助手' }, + { id: 1000000002, name: '代码助手' }, +] + +// hasValue:复刻组件中的判定 +function hasValue(modelValue: string | number | null | undefined): boolean { + return modelValue !== '' && modelValue !== null && modelValue !== undefined +} + +// selectedAgent:复刻组件中的查找 +function findSelected( + modelValue: string | number | null | undefined, + agents: PickableAgent[], +): PickableAgent | null { + if (!hasValue(modelValue)) return null + return agents.find(a => String(a.id) === String(modelValue)) || null +} + +// isUnknown:复刻组件中的 computed —— 关键修复点 +// hasValue && !selectedAgent && agents.length > 0 +function computeIsUnknown( + modelValue: string | number | null | undefined, + agents: PickableAgent[], +): boolean { + const selected = findSelected(modelValue, agents) + return hasValue(modelValue) && !selected && agents.length > 0 +} + +// triggerLabel:复刻组件中的优先级链 +function computeTriggerLabel( + modelValue: string | number | null | undefined, + agents: PickableAgent[], + placeholder: string, + unknownLabel: string, +): string { + const selected = findSelected(modelValue, agents) + if (selected) return selected.name + if (computeIsUnknown(modelValue, agents)) return unknownLabel + return placeholder +} + +describe('AgentPickerDialog isUnknown 判定', () => { + describe('空值场景', () => { + it('modelValue 为 null 时不判定为未知', () => { + expect(computeIsUnknown(null, AGENTS)).toBe(false) + }) + it('modelValue 为空字符串时不判定为未知', () => { + expect(computeIsUnknown('', AGENTS)).toBe(false) + }) + it('modelValue 为 undefined 时不判定为未知', () => { + expect(computeIsUnknown(undefined, AGENTS)).toBe(false) + }) + }) + + describe('正常匹配场景', () => { + it('数字 id 匹配到员工时不判定为未知', () => { + expect(computeIsUnknown(1000000001, AGENTS)).toBe(false) + }) + it('字符串 id 匹配到员工时不判定为未知(Snowflake 精度场景)', () => { + expect(computeIsUnknown('1000000001', AGENTS)).toBe(false) + }) + }) + + describe('列表为空场景(Bug 1 核心修复点)', () => { + // 组件被 keepAlive 重建后 /agents 尚在飞行中,此时 agents=[] + // 旧逻辑:hasValue && !selectedAgent → true → 显示原始 ID + // 新逻辑:追加 agents.length > 0 → false → 走 placeholder + it('modelValue 有值但员工列表为空时不判定为未知', () => { + expect(computeIsUnknown(1000000001, [])).toBe(false) + expect(computeIsUnknown('1000000001', [])).toBe(false) + }) + it('modelValue 为无效值且列表为空时也不判定为未知', () => { + expect(computeIsUnknown(9999999999, [])).toBe(false) + }) + }) + + describe('员工被删除/改名场景', () => { + it('modelValue 有值、列表非空但无匹配时判定为未知', () => { + expect(computeIsUnknown(9999999999, AGENTS)).toBe(true) + expect(computeIsUnknown('9999999999', AGENTS)).toBe(true) + }) + it('被删除的员工在恢复列表后仍显示未知标记,直到用户重新选择', () => { + // 模拟:员工 1000000001 被从列表移除 + const reduced = AGENTS.filter(a => a.id !== 1000000001) + expect(computeIsUnknown(1000000001, reduced)).toBe(true) + }) + }) +}) + +describe('AgentPickerDialog triggerLabel 优先级链', () => { + const PLACEHOLDER = '请选择员工' + const UNKNOWN_LABEL = '未知员工' + + it('选中员工时显示员工名称(最高优先级)', () => { + expect(computeTriggerLabel(1000000001, AGENTS, PLACEHOLDER, UNKNOWN_LABEL)) + .toBe('通用助手') + }) + + it('列表为空 + modelValue 有值时显示 placeholder(Bug 1 修复核心)', () => { + // 旧逻辑会显示 "1000000001",新逻辑走 placeholder + expect(computeTriggerLabel(1000000001, [], PLACEHOLDER, UNKNOWN_LABEL)) + .toBe(PLACEHOLDER) + }) + + it('列表非空但无匹配时显示 unknownLabel', () => { + expect(computeTriggerLabel(9999999999, AGENTS, PLACEHOLDER, UNKNOWN_LABEL)) + .toBe(UNKNOWN_LABEL) + }) + + it('未选择时显示 placeholder', () => { + expect(computeTriggerLabel(null, AGENTS, PLACEHOLDER, UNKNOWN_LABEL)) + .toBe(PLACEHOLDER) + }) +}) diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 63cbf7c7..6a4a1006 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1218,6 +1218,7 @@ export default { title: 'Agent Context', desc: 'Manage prompt files and memory for agents', selectAgent: 'Select Employee', + unknownAgent: 'Unknown employee', noAgent: 'Please select an agent first', files: 'Files', coreFiles: 'Core Files', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index a1ca252c..877e248f 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1092,6 +1092,7 @@ export default { title: '智能体上下文', desc: '管理智能体的提示文件和记忆', selectAgent: '选择员工', + unknownAgent: '未知员工', noAgent: '请先选择一个智能体', files: '文件列表', coreFiles: '核心文件', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index c7c1890c..23541516 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -25,7 +25,7 @@ const router = createRouter({ path: 'chat', name: 'Chat', component: () => import('@/views/ChatConsole.vue'), - meta: { title: 'Chat', requiredCapability: 'chat' }, + meta: { title: 'Chat', requiredCapability: 'chat', keepAlive: true }, }, { path: 'dashboard', diff --git a/mateclaw-ui/src/stores/__tests__/wikiStoreWorkspaceSwitch.test.ts b/mateclaw-ui/src/stores/__tests__/wikiStoreWorkspaceSwitch.test.ts new file mode 100644 index 00000000..b3a7d878 --- /dev/null +++ b/mateclaw-ui/src/stores/__tests__/wikiStoreWorkspaceSwitch.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +// vi.mock 在顶层会被提升,工厂内部不能引用外部变量,所以直接返回固定 +// 的 mock 实现,具体的返回数据在测试用例中通过 mockResolvedValue 设置。 +vi.mock('@/api/index', () => ({ + wikiApi: { + listKBs: vi.fn(), + listRaw: vi.fn().mockResolvedValue({ data: [] }), + listPages: vi.fn().mockResolvedValue({ data: [] }), + listPageRefs: vi.fn().mockResolvedValue({ data: { items: [] } }), + getBrokenLinksReport: vi.fn().mockRejectedValue({ code: 404 }), + getPageTypeProfile: vi.fn().mockRejectedValue(new Error('no profile')), + }, +})) + +// 必须在 mock 之后导入,否则 store 初始化时拿到的是真实 wikiApi +import { useWikiStore } from '../useWikiStore' +import { wikiApi } from '@/api/index' + +const KB_WS_A = { id: 100, name: 'WS-A-Wiki' } +const KB_WS_B = { id: 200, name: 'WS-B-Wiki' } + +describe('useWikiStore 工作区切换 KB 清理', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('切换工作区后 currentKB 不在新列表中时清理旧 KB 上下文', async () => { + const store = useWikiStore() + // 模拟工作区 A:已选中 KB 并加载了 pages / pageRefs + store.currentKB = KB_WS_A as any + store.pages = [{ id: 1, title: 'page-A' }] as any + store.pageRefs = [{ slug: 'page-a', title: 'Page A', archived: false }] as any + + // 切到工作区 B:listKBs 返回的列表不含 id=100 + ;(wikiApi.listKBs as any).mockResolvedValue({ data: [KB_WS_B] }) + + await store.fetchKnowledgeBases() + + // backToLibrary 应被触发:currentKB / pages / pageRefs 全部归零 + expect(store.currentKB).toBeNull() + expect(store.pages).toEqual([]) + expect(store.pageRefs).toEqual([]) + // 新列表已写入 + expect(store.knowledgeBases).toEqual([KB_WS_B]) + expect(store.loading).toBe(false) + }) + + it('同一工作区刷新 KB 列表时保留当前 currentKB', async () => { + const store = useWikiStore() + store.currentKB = KB_WS_A as any + store.pages = [{ id: 1, title: 'page-A' }] as any + + // 同一工作区:listKBs 返回的列表包含 id=100 + ;(wikiApi.listKBs as any).mockResolvedValue({ + data: [KB_WS_A, { id: 101, name: 'WS-A-Wiki-2' }], + }) + + await store.fetchKnowledgeBases() + + // currentKB 保留,pages 保留(不误清理) + expect(store.currentKB).not.toBeNull() + expect(store.currentKB?.id).toBe(100) + expect(store.pages).toEqual([{ id: 1, title: 'page-A' }]) + }) + + it('首次进入(无 currentKB)时不触发 backToLibrary', async () => { + const store = useWikiStore() + // currentKB 初始为 null + expect(store.currentKB).toBeNull() + + ;(wikiApi.listKBs as any).mockResolvedValue({ data: [KB_WS_A, KB_WS_B] }) + + await store.fetchKnowledgeBases() + + expect(store.knowledgeBases).toHaveLength(2) + expect(store.loading).toBe(false) + // 无异常即可,backToLibrary 对 null currentKB 本身也是安全空操作 + }) + + it('listKBs 接口异常时不崩溃且 loading 复位', async () => { + const store = useWikiStore() + store.currentKB = KB_WS_A as any + + ;(wikiApi.listKBs as any).mockRejectedValue(new Error('network down')) + + await store.fetchKnowledgeBases() + + // 异常路径:catch 吞错,knowledgeBases 不变,loading 复位 + expect(store.loading).toBe(false) + expect(store.currentKB?.id).toBe(100) + }) + + it('selectKB 后切换工作区触发 fetchKnowledgeBases 能正确清理', async () => { + // 验证 selectKB → fetchKnowledgeBases 的组合路径 + const store = useWikiStore() + ;(wikiApi.listKBs as any).mockResolvedValue({ data: [KB_WS_B] }) + + // 先在工作区 A 选了 KB + store.currentKB = KB_WS_A as any + store.pages = [{ id: 1, title: 'page-A' }] as any + + await store.fetchKnowledgeBases() + + // 旧 KB 上下文被清理 + expect(store.currentKB).toBeNull() + expect(store.pages).toEqual([]) + }) +}) diff --git a/mateclaw-ui/src/stores/useWikiStore.ts b/mateclaw-ui/src/stores/useWikiStore.ts index 30ddd0e3..72a5f893 100644 --- a/mateclaw-ui/src/stores/useWikiStore.ts +++ b/mateclaw-ui/src/stores/useWikiStore.ts @@ -177,7 +177,13 @@ export const useWikiStore = defineStore('wiki', () => { loading.value = true try { const res: any = await wikiApi.listKBs() - knowledgeBases.value = res.data || [] + const next: WikiKB[] = res.data || [] + // 工作区切换时清理上一个工作区的 KB 上下文,避免显示其内容。 + // backToLibrary 已清理 pages/pageRefs/rawMaterials 等关联状态,这里复用。 + if (currentKB.value && !next.some(kb => kb.id === currentKB.value!.id)) { + backToLibrary() + } + knowledgeBases.value = next } catch (e) { console.error('Failed to fetch knowledge bases', e) } finally { diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index c8716882..9a86b5c2 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -263,8 +263,12 @@ + +