mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(ui): resolve state confusion and cross-user data leak when switching menus/workspaces
This commit is contained in:
parent
6252dfb81a
commit
d950d54b00
@ -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.
|
||||
|
||||
@ -204,12 +204,18 @@ const selectedAgent = computed<PickableAgent | null>(() => {
|
||||
})
|
||||
|
||||
/** 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')
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
|
||||
@ -1092,6 +1092,7 @@ export default {
|
||||
title: '智能体上下文',
|
||||
desc: '管理智能体的提示文件和记忆',
|
||||
selectAgent: '选择员工',
|
||||
unknownAgent: '未知员工',
|
||||
noAgent: '请先选择一个智能体',
|
||||
files: '文件列表',
|
||||
coreFiles: '核心文件',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -263,8 +263,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
let cachedAgents: import('@/types').Agent[] = []
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
@ -359,7 +363,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const agents = ref<Agent[]>([])
|
||||
const agents = ref<Agent[]>(cachedAgents.length > 0 ? [...cachedAgents] : [])
|
||||
const conversations = ref<Conversation[]>([])
|
||||
const selectedAgentId = ref<string | number>('')
|
||||
const currentConversationId = ref<string>('')
|
||||
@ -1162,6 +1166,45 @@ onBeforeUnmount(() => {
|
||||
revokeAllPreviewUrls()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
if (activityPollTimer !== null) {
|
||||
clearInterval(activityPollTimer)
|
||||
activityPollTimer = null
|
||||
}
|
||||
if (elapsedTickTimer !== null) {
|
||||
clearInterval(elapsedTickTimer)
|
||||
elapsedTickTimer = null
|
||||
}
|
||||
document.removeEventListener('click', handleCodeCopy)
|
||||
disposeECharts()
|
||||
disposeKatex()
|
||||
disposeMermaid()
|
||||
revokeAllPreviewUrls()
|
||||
resetForNewConversation()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
document.addEventListener('click', handleCodeCopy)
|
||||
startECharts()
|
||||
startKatex()
|
||||
startMermaid()
|
||||
activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS)
|
||||
elapsedTickTimer = window.setInterval(() => {
|
||||
if (activeCronRuns.value.length > 0) elapsedNow.value = Date.now()
|
||||
}, 1000)
|
||||
// 登出已改为 window.location.href(刷新页面),所以切回时不会有跨用户残留
|
||||
if (currentConversationId.value && !isEphemeralConversation(currentConversationId.value)) {
|
||||
try {
|
||||
const statusRes: any = await conversationApi.getStatus(currentConversationId.value)
|
||||
if (currentConversationId.value && statusRes.data?.streamStatus === 'running') {
|
||||
await reconnectStream(currentConversationId.value)
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query, () => {
|
||||
// If a fresh action arrives (e.g. user re-fires Ctrl+K via the URL while
|
||||
// the view is already alive), pick it up immediately.
|
||||
@ -1331,6 +1374,7 @@ async function loadAgents() {
|
||||
// a confusing failure path. The admin Agents view passes no filter.
|
||||
const res: any = await agentApi.list({ enabled: true })
|
||||
agents.value = res.data || []
|
||||
if (agents.value.length > 0) cachedAgents = [...agents.value]
|
||||
// 只有在 URL 没有指定 agentId 且当前无选中时,才默认选第一个
|
||||
if (agents.value.length > 0 && !selectedAgentId.value && !route.query.agentId) {
|
||||
selectedAgentId.value = agents.value[0].id
|
||||
|
||||
@ -578,7 +578,9 @@ function logout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('role')
|
||||
router.push('/login')
|
||||
// 刷新页面而非 router.push:确保 keepAlive 缓存的 ChatConsole、
|
||||
// 模块级变量(cachedAgents 等)全部清空,杜绝跨用户数据泄漏。
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
async function changeLocale(locale: AppLocale) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user