mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(ui): refresh agent context previews
This commit is contained in:
parent
d1592cc922
commit
04209c3f7f
@ -51,7 +51,7 @@
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" @click="fetchFiles" :title="t('common.reset')">
|
||||
<button class="icon-btn" @click="refreshFilesAndSelection" :title="t('common.reset')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
@ -385,6 +385,7 @@ const originalContent = ref('')
|
||||
const saving = ref(false)
|
||||
// 'off' = editor only, 'split' = side-by-side, 'preview' = preview only
|
||||
const previewMode = ref<'off' | 'split' | 'preview'>('off')
|
||||
let fileLoadRequestId = 0
|
||||
|
||||
// 新建文件
|
||||
const showNewFileDialog = ref(false)
|
||||
@ -479,6 +480,7 @@ onMounted(async () => {
|
||||
|
||||
watch(selectedAgentId, () => {
|
||||
if (selectedAgentId.value) {
|
||||
fileLoadRequestId += 1
|
||||
selectedFile.value = null
|
||||
fileContent.value = ''
|
||||
originalContent.value = ''
|
||||
@ -511,6 +513,32 @@ async function fetchFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFilesAndSelection() {
|
||||
await fetchFiles()
|
||||
if (!selectedFile.value) return
|
||||
|
||||
if (isPersonalSelected.value) {
|
||||
await fetchPersonalFiles()
|
||||
const latestPersonal = personalFiles.value.find(file =>
|
||||
file.filename === selectedFile.value?.filename
|
||||
&& file.ownerKey === selectedFile.value?.ownerKey)
|
||||
if (latestPersonal) {
|
||||
await onPersonalFileClick(latestPersonal)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const latest = files.value.find(file => file.filename === selectedFile.value?.filename)
|
||||
if (latest) {
|
||||
await onFileClick(latest)
|
||||
} else {
|
||||
fileLoadRequestId += 1
|
||||
selectedFile.value = null
|
||||
fileContent.value = ''
|
||||
originalContent.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPersonalFiles() {
|
||||
if (!selectedAgentId.value) return
|
||||
try {
|
||||
@ -556,9 +584,17 @@ function handlePreviewClick(e: MouseEvent) {
|
||||
}
|
||||
|
||||
async function onFileClick(file: WorkspaceFile) {
|
||||
const requestId = ++fileLoadRequestId
|
||||
const agentId = selectedAgentId.value
|
||||
selectedFile.value = file
|
||||
try {
|
||||
const res: any = await agentContextApi.getFile(selectedAgentId.value, file.filename)
|
||||
const res: any = await agentContextApi.getFile(agentId, file.filename)
|
||||
if (
|
||||
requestId !== fileLoadRequestId
|
||||
|| selectedAgentId.value !== agentId
|
||||
|| selectedFile.value?.filename !== file.filename
|
||||
|| isPersonalSelected.value
|
||||
) return
|
||||
const data = res.data
|
||||
fileContent.value = data?.content || ''
|
||||
originalContent.value = fileContent.value
|
||||
@ -568,12 +604,21 @@ async function onFileClick(file: WorkspaceFile) {
|
||||
}
|
||||
|
||||
async function onPersonalFileClick(file: WorkspaceFile) {
|
||||
const requestId = ++fileLoadRequestId
|
||||
const agentId = selectedAgentId.value
|
||||
selectedFile.value = file
|
||||
// Read-only view — markdown preview is the most useful default
|
||||
previewMode.value = 'preview'
|
||||
try {
|
||||
const res: any = await agentContextApi.getPersonalFile(
|
||||
selectedAgentId.value, file.filename, file.ownerKey || '')
|
||||
agentId, file.filename, file.ownerKey || '')
|
||||
if (
|
||||
requestId !== fileLoadRequestId
|
||||
|| selectedAgentId.value !== agentId
|
||||
|| selectedFile.value?.filename !== file.filename
|
||||
|| selectedFile.value?.ownerKey !== file.ownerKey
|
||||
|| !isPersonalSelected.value
|
||||
) return
|
||||
fileContent.value = res.data?.content || ''
|
||||
originalContent.value = fileContent.value
|
||||
} catch {
|
||||
@ -631,6 +676,7 @@ async function confirmDeleteFile() {
|
||||
try {
|
||||
await agentContextApi.deleteFile(selectedAgentId.value, name)
|
||||
mcToast.success(t('agentContext.deleteSuccess'))
|
||||
fileLoadRequestId += 1
|
||||
selectedFile.value = null
|
||||
fileContent.value = ''
|
||||
originalContent.value = ''
|
||||
|
||||
218
mateclaw-ui/src/views/__tests__/AgentContext.test.ts
Normal file
218
mateclaw-ui/src/views/__tests__/AgentContext.test.ts
Normal file
@ -0,0 +1,218 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AgentContext from '../AgentContext.vue'
|
||||
import { agentApi, agentContextApi } from '@/api/index'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/index', () => ({
|
||||
agentApi: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
agentContextApi: {
|
||||
listFiles: vi.fn(),
|
||||
getFile: vi.fn(),
|
||||
saveFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
listPersonalFiles: vi.fn(),
|
||||
getPersonalFile: vi.fn(),
|
||||
getPromptFiles: vi.fn(),
|
||||
setPromptFiles: vi.fn(),
|
||||
exportMemorySnapshot: vi.fn(),
|
||||
previewImportMemorySnapshot: vi.fn(),
|
||||
applyImportMemorySnapshot: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMcToast', () => ({
|
||||
mcToast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/useConfirm', () => ({
|
||||
mcConfirm: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMermaidRenderer', () => ({
|
||||
handleMermaidDownload: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMarkdownRenderer', () => ({
|
||||
useMarkdownRenderer: () => ({
|
||||
renderMarkdown: (value: string) => `<p>${value}</p>`,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/AgentPickerDialog.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'AgentPickerDialogStub',
|
||||
props: {
|
||||
modelValue: [String, Number],
|
||||
agents: Array,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div class="agent-picker-stub"></div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = []
|
||||
|
||||
function mountAgentContext() {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const app = createApp(AgentContext)
|
||||
app.use(createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': {} } }))
|
||||
app.mount(host)
|
||||
apps.push(app)
|
||||
return host
|
||||
}
|
||||
|
||||
async function eventually(assertion: () => void) {
|
||||
let lastError: unknown
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await Promise.resolve()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
try {
|
||||
assertion()
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(next => { resolve = next })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('AgentContext previews', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(agentApi.list).mockResolvedValue({
|
||||
data: [{ id: 'agent-1', name: '会议管理' }],
|
||||
} as never)
|
||||
vi.mocked(agentContextApi.getPromptFiles).mockResolvedValue({ data: ['AGENTS.md'] } as never)
|
||||
vi.mocked(agentContextApi.listPersonalFiles).mockResolvedValue({ data: [] } as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
apps.splice(0).forEach(app => app.unmount())
|
||||
document.body.innerHTML = ''
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reloads the selected file body when refreshing so split and preview modes show latest content', async () => {
|
||||
let version = 1
|
||||
vi.mocked(agentContextApi.listFiles).mockImplementation(async () => ({
|
||||
data: [{
|
||||
id: 'file-1',
|
||||
agentId: 'agent-1',
|
||||
filename: 'AGENTS.md',
|
||||
fileSize: version === 1 ? 8 : 14,
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
createTime: '2026-08-14T10:00:00',
|
||||
updateTime: version === 1 ? '2026-08-14T10:00:00' : '2026-08-14T10:01:00',
|
||||
}],
|
||||
}) as never)
|
||||
vi.mocked(agentContextApi.getFile).mockImplementation(async () => ({
|
||||
data: { content: version === 1 ? 'old content' : 'latest content' },
|
||||
}) as never)
|
||||
|
||||
const host = mountAgentContext()
|
||||
await eventually(() => {
|
||||
expect(host.querySelector('.file-item')).not.toBeNull()
|
||||
})
|
||||
|
||||
host.querySelector<HTMLElement>('.file-item')!.click()
|
||||
await eventually(() => {
|
||||
expect(host.querySelector<HTMLTextAreaElement>('.editor-textarea')?.value).toBe('old content')
|
||||
})
|
||||
host.querySelectorAll<HTMLButtonElement>('.preview-mode-btn')[2].click()
|
||||
await eventually(() => {
|
||||
expect(host.textContent).toContain('old content')
|
||||
})
|
||||
|
||||
version = 2
|
||||
host.querySelectorAll<HTMLButtonElement>('.panel-actions .icon-btn')[3].click()
|
||||
|
||||
await eventually(() => {
|
||||
expect(host.textContent).toContain('latest content')
|
||||
})
|
||||
expect(host.textContent).not.toContain('old content')
|
||||
})
|
||||
|
||||
it('ignores stale file loads when the user selects another file before the first request resolves', async () => {
|
||||
const agentsLoaded = deferred<void>()
|
||||
vi.mocked(agentApi.list).mockImplementation(async () => {
|
||||
agentsLoaded.resolve()
|
||||
return { data: [{ id: 'agent-1', name: '会议管理' }] } as never
|
||||
})
|
||||
vi.mocked(agentContextApi.listFiles).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'file-1',
|
||||
agentId: 'agent-1',
|
||||
filename: 'AGENTS.md',
|
||||
fileSize: 8,
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
createTime: '2026-08-14T10:00:00',
|
||||
updateTime: '2026-08-14T10:00:00',
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
agentId: 'agent-1',
|
||||
filename: 'MEMORY.md',
|
||||
fileSize: 14,
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
createTime: '2026-08-14T10:00:00',
|
||||
updateTime: '2026-08-14T10:01:00',
|
||||
},
|
||||
],
|
||||
} as never)
|
||||
|
||||
const agentsFileLoaded = deferred<{ data: { content: string } }>()
|
||||
vi.mocked(agentContextApi.getFile).mockImplementation((_, filename) => {
|
||||
if (filename === 'AGENTS.md') {
|
||||
return agentsFileLoaded.promise as never
|
||||
}
|
||||
return Promise.resolve({ data: { content: 'memory latest' } }) as never
|
||||
})
|
||||
|
||||
const host = mountAgentContext()
|
||||
await agentsLoaded.promise
|
||||
await eventually(() => {
|
||||
expect(host.querySelectorAll('.file-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
const fileItems = host.querySelectorAll<HTMLElement>('.file-item')
|
||||
fileItems[0].click()
|
||||
fileItems[1].click()
|
||||
|
||||
await eventually(() => {
|
||||
expect(host.querySelector<HTMLTextAreaElement>('.editor-textarea')?.value).toBe('memory latest')
|
||||
})
|
||||
|
||||
agentsFileLoaded.resolve({ data: { content: 'agents stale' } })
|
||||
|
||||
await eventually(() => {
|
||||
expect(host.querySelector<HTMLTextAreaElement>('.editor-textarea')?.value).toBe('memory latest')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user