Improve knowledge workspace and chat file import

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 11:34:53 +08:00
1 parent 23ddce11da
commit c634425cc6
11 files changed
+1078 -118

No files matched your search

+254 -30
View File
@@ -1,20 +1,36 @@
import { test, expect, type Page } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { resolve } from 'node:path';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile } from '../../shared/cloud-agents';
import { build } from 'vite';
import { createServer, type Server } from 'node:http';
import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve, sep } from 'node:path';
import { createRequire } from 'node:module';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile, type CloudThread, type CloudAttachment } from '../../shared/cloud-agents';
let server: ViteDevServer;
let server: Server;
let baseUrl: string;
let fixtureDir: string;
const fixtureRequire = createRequire(resolve('tests/e2e/cloud-agent-knowledge.spec.ts'));
const viteDependencies = createRequire(fixtureRequire.resolve('vite/package.json'));
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
// Render the real editor while replacing only the Main IPC boundary. No Electron,
// native picker, real upload or cloud request is started by this suite.
test.beforeAll(async () => {
test.setTimeout(90_000);
fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-knowledge-fixture-')));
await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir');
const html = join(fixtureDir, 'index.html');
const output = join(fixtureDir, 'dist');
await writeFile(html, '<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-knowledge-harness.tsx"></script></body></html>');
const entry = resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx');
server = await createServer({
configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-knowledge',
// Build the virtual entry once. A static fixture needs no Vite dev server,
// background dependency discovery, file watcher, or HMR connection.
await build({
configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn',
resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' },
server: { host: '127.0.0.1', port: 0 },
css: { postcss: resolve('.') },
build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } },
plugins: [{
name: 'isolated-cloud-agent-knowledge', enforce: 'pre',
resolveId(id) { if (id === '/__cloud-agent-knowledge-harness.tsx') return entry; },
@@ -22,25 +38,37 @@ test.beforeAll(async () => {
if (id === entry) return `
import { createRoot } from 'react-dom/client';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import CloudAgents from '/src/pages/CloudAgents/index.tsx';
import '/src/styles/globals.css';
import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))};
import ${JSON.stringify(resolve('src/styles/globals.css'))};
const router = createMemoryRouter([{ path: '/cloud-agents', element: <CloudAgents /> }], { initialEntries: ['/cloud-agents?view=mine'] });
createRoot(document.getElementById('root')).render(<div className="h-screen min-w-0"><RouterProvider router={router} /></div>);
`;
if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "knowledge-fixture" } }); }';
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-knowledge-harness.tsx"></script></body></html>');
});
},
}],
});
await server.listen(); baseUrl = server.resolvedUrls!.local[0];
const mime: Record<string, string> = { html: 'text/html', js: 'text/javascript', css: 'text/css',
svg: 'image/svg+xml', png: 'image/png', woff2: 'font/woff2', woff: 'font/woff' };
server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname);
const file = resolve(output, '.' + (pathname === '/' ? '/index.html' : pathname));
if (!file.startsWith(output + sep)) { res.writeHead(403); res.end(); return; }
try { const bytes = await readFile(file); res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(bytes); }
catch { res.writeHead(404); res.end(); }
});
await new Promise<void>((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); });
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Fixture HTTP address is unavailable');
baseUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => {
try {
if (server?.listening) { server.closeAllConnections(); await new Promise<void>((done, reject) => server.close(error => error ? reject(error) : done())); }
} finally {
try { if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true }); }
finally { (viteDependencies('esbuild') as { stop(): void }).stop(); }
}
});
test.afterAll(async () => { await server?.close(); });
const agent: CloudAgentDraft = {
slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '用我的资料回答问题。',
@@ -54,20 +82,37 @@ const libraries: CloudKnowledge[] = [
{ kb_id: 'kb_fixturereading', name: '阅读知识', description: '平台开放的阅读资料', embedding_model: 'fixture-embedding' },
{ kb_id: 'kb_fixturepersonal', name: '我的观察记录', description: '自己的资料', embedding_model: 'fixture-embedding' },
];
const sourceThreads: CloudThread[] = [
{ thread_id: 'thread-spring', client_thread_id: 'client-spring', agent_slug: agent.slug, title: '春天的观察讨论', mode: 'preview',
updated_at: '2026-10-13T01:00:00Z', run_id: null, status: 'completed', unread: false },
{ thread_id: 'thread-reading', client_thread_id: 'client-reading', agent_slug: agent.slug, title: '图书阅读记录', mode: 'preview',
updated_at: '2026-10-13T00:00:00Z', run_id: null, status: 'completed', unread: false },
];
const sourceAttachments: Record<string, CloudAttachment[]> = {
'thread-spring': [
{ file_id: 'attachment-observations', file_name: '春天观察报告.md', file_size: 1536, path: 'attachments/spring/report.md', original_path: 'attachments/spring/report.md' },
{ file_id: 'attachment-plants', file_name: '植物清单.csv', file_size: 2048, path: 'attachments/spring/plants.csv', original_path: 'attachments/spring/plants.csv' },
],
'thread-reading': [
{ file_id: 'attachment-reading', file_name: '阅读笔记.pdf', file_size: 2048, path: 'attachments/reading/notes.pdf', original_path: 'attachments/reading/notes.pdf' },
],
};
type FixtureCall = { path: string; method: string; input: Record<string, unknown> | null };
type KnowledgeFixture = {
agent: CloudAgentDraft; libraries: CloudKnowledge[]; files: Record<string, CloudKnowledgeFile[]>;
calls: FixtureCall[]; ownedIds: string[]; upload: 'success' | 'cancel' | 'fail'; processFailure: boolean;
threads: CloudThread[]; attachments: Record<string, CloudAttachment[]>; importFailures: string[];
};
type FixtureWindow = Window & { __knowledgeFixture: KnowledgeFixture };
async function installFixture(page: Page) {
page.on('pageerror', error => { throw error; });
await page.addInitScript(({ agent, libraries }) => {
await page.addInitScript(({ agent, libraries, sourceThreads, sourceAttachments }) => {
const scope = window as unknown as FixtureWindow;
const stored = sessionStorage.getItem('knowledge-fixture');
const state: KnowledgeFixture = stored ? JSON.parse(stored) : {
agent, libraries, files: {}, calls: [], ownedIds: ['kb_fixturepersonal'], upload: 'success', processFailure: false,
threads: sourceThreads, attachments: sourceAttachments, importFailures: [],
};
scope.__knowledgeFixture = state;
const persist = () => sessionStorage.setItem('knowledge-fixture', JSON.stringify(state));
@@ -113,6 +158,19 @@ async function installFixture(page: Page) {
case 'knowledgeFiles':
if (!managed(args?.kb_id)) throw new Error('Document management reached a read-only library');
return result({ files: state.files[String(args?.kb_id)] ?? [], next_offset: null });
case 'threads': return result({ threads: state.threads, next_offset: null });
case 'received': return result({ agents: [] });
case 'attachments': return result({ attachments: state.attachments[String(args?.thread_id)] ?? [] });
case 'importKnowledgeAttachment': {
if (!managed(args?.kb_id)) throw new Error('Attachment import reached a read-only library');
const attachment = state.attachments[String(args?.thread_id)]?.find(file => file.file_id === args?.attachment_id);
if (!attachment) throw new Error('Unknown fixture source attachment');
if (state.importFailures.includes(attachment.file_id)) return failure('植物清单导入暂时失败');
const file: CloudKnowledgeFile = { file_id: 'imported-' + attachment.file_id, name: attachment.file_name,
size: attachment.file_size, status: 'uploaded', error: null, chunk_count: 0, available: false };
state.files[String(args?.kb_id)] = [file, ...(state.files[String(args?.kb_id)] ?? []).filter(item => item.file_id !== file.file_id)];
return result(file);
}
case 'processKnowledge': {
if (!managed(args?.kb_id)) throw new Error('Processing reached a read-only library');
if (state.processFailure) return failure('处理请求暂不可用');
@@ -125,7 +183,7 @@ async function installFixture(page: Page) {
}
},
} } });
}, { agent, libraries });
}, { agent, libraries, sourceThreads, sourceAttachments });
}
async function openKnowledge(page: Page) {
@@ -150,6 +208,36 @@ async function expectNoClipping(page: Page) {
});
expect(clipped).toEqual([]);
}
async function expectKnowledgeLayout(page: Page, width: number) {
const layout = page.locator('.agent-knowledge-workspace');
const upload = await layout.locator('.agent-knowledge-upload').boundingBox();
const selection = await layout.locator('.agent-knowledge-selection').boundingBox();
expect(upload).not.toBeNull(); expect(selection).not.toBeNull();
if (width >= 1024) {
expect(upload!.x + upload!.width).toBeLessThanOrEqual(selection!.x + 1);
expect(Math.abs(upload!.y - selection!.y)).toBeLessThanOrEqual(1);
} else {
expect(upload!.y + upload!.height).toBeLessThanOrEqual(selection!.y + 1);
expect(Math.abs(upload!.x - selection!.x)).toBeLessThanOrEqual(1);
}
await expectNoClipping(page);
}
async function expectPickerBounds(page: Page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
const outside = await page.getByRole('dialog', { name: '从聊天选择文件', exact: true }).evaluate(dialog => {
const bounds = dialog.getBoundingClientRect();
return {
bounds: { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom },
viewport: { width: innerWidth, height: innerHeight },
dialogFits: bounds.left >= 0 && bounds.top >= 0 && bounds.right <= innerWidth && bounds.bottom <= innerHeight,
clipped: [...dialog.querySelectorAll('button, input, label, h2, h4')].filter(element => element.getClientRects().length > 0)
.filter(element => { const box = element.getBoundingClientRect(); return box.left < bounds.left - 1 || box.right > bounds.right + 1; })
.map(element => element.getAttribute('aria-label') ?? element.textContent?.trim()),
};
});
expect(outside.dialogFits, JSON.stringify(outside)).toBe(true); expect(outside.clipped).toEqual([]);
if (outside.viewport.width >= 1024) expect(outside.bounds.right - outside.bounds.left).toBeGreaterThanOrEqual(800);
}
for (const width of [375, 1440]) {
test(`multiple shared/personal choices stay draft-only and survive save/reopen at ${width}px`, async ({ page }) => {
@@ -168,11 +256,11 @@ for (const width of [375, 1440]) {
await expect(selected.getByRole('button', { name: '移除知识库 科学知识', exact: true })).toBeVisible();
await expect(selected.getByRole('button', { name: /上传|删除|处理|替换/ })).toHaveCount(0);
expect((await calls(page)).filter(call => call.method === 'PATCH' || call.path.endsWith('/knowledge/pick'))).toEqual([]);
await knowledge.getByText('上传自己的文件', { exact: true }).click();
await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible();
const management = knowledge.getByRole('combobox', { name: '管理的知识库', exact: true });
await expect(management.locator('option[value="kb_fixturescience"], option[value="kb_fixturereading"]')).toHaveCount(0);
await expect(knowledge.getByRole('button', { name: '上传文档', exact: true })).toHaveCount(0);
await expectNoClipping(page); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' });
await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toHaveCount(0);
await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' });
await editor.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible();
expect((await calls(page)).filter(call => call.method === 'PATCH').map(call => (call.input?.configuration as { knowledges: string[] }).knowledges))
@@ -186,7 +274,7 @@ for (const width of [375, 1440]) {
await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl);
const { editor, knowledge } = await openKnowledge(page);
await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience');
await knowledge.getByText('上传自己的文件', { exact: true }).click();
await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible();
expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || ['createKnowledge', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]);
await knowledge.getByRole('button', { name: '新建知识库', exact: true }).click();
await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料');
@@ -195,7 +283,7 @@ for (const width of [375, 1440]) {
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated');
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料');
expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]);
await knowledge.getByRole('button', { name: '上传文档', exact: true }).click();
await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click();
const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true });
await expect(document.getByText('等待处理', { exact: true })).toBeVisible();
const mutations = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge');
@@ -210,7 +298,7 @@ for (const width of [375, 1440]) {
await expect(document.getByText('可用于回答', { exact: true })).toBeVisible({ timeout: 12_000 });
await expect(document.getByText('2 KB · 6 段可检索内容', { exact: true })).toBeVisible();
await document.scrollIntoViewIfNeeded();
await expectNoClipping(page); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' });
await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' });
// Document mutations never implicitly save the Agent draft.
expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]);
await editor.getByRole('button', { name: '保存草稿', exact: true }).click();
@@ -218,6 +306,52 @@ for (const width of [375, 1440]) {
expect(await page.evaluate(() => (window as unknown as FixtureWindow).__knowledgeFixture.agent.configuration.knowledges))
.toEqual(['kb_fixturescience', 'kb_fixturecreated']);
});
test(`chat files show their source and metadata and import multiple choices into the named own library at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl);
const { knowledge } = await openKnowledge(page);
await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience');
await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal');
await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click();
const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true });
await expect(picker).toContainText('导入到「我的观察记录」');
await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('春天');
await expect(picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true })).toHaveCount(0);
await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click();
await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toBeVisible();
await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }) }))
.toContainText(/(?:MD|Markdown)\s*·\s*(?:1\.5|2)\s*KB/i);
await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill('植物');
await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toHaveCount(0);
await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check();
await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }) }))
.toContainText(/CSV\s*·\s*2\s*KB/);
await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill('');
await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check();
await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('图书');
await picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true }).click();
await picker.getByRole('checkbox', { name: '选择文件 阅读笔记.pdf', exact: true }).check();
const confirm = picker.getByRole('button', { name: '导入 3 个文件', exact: true });
await expect(confirm).toBeEnabled();
await expect(confirm).toBeInViewport();
expect((await calls(page)).filter(call => ['importKnowledgeAttachment', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]);
await expectPickerBounds(page);
await page.screenshot({ path: test.info().outputPath(`knowledge-chat-picker-${width}.png`), animations: 'disabled' });
await confirm.click();
await expect(picker).toHaveCount(0);
for (const name of ['春天观察报告.md', '植物清单.csv', '阅读笔记.pdf']) {
await expect(knowledge.getByRole('article', { name, exact: true })).toBeVisible();
}
const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment');
expect(imports).toHaveLength(3);
expect(imports.map(call => (call.input?.input as { attachment_id: string }).attachment_id).sort())
.toEqual(['attachment-observations', 'attachment-plants', 'attachment-reading']);
expect(imports.every(call => (call.input?.input as { kb_id: string }).kb_id === 'kb_fixturepersonal')).toBe(true);
expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toHaveLength(3);
expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]);
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识');
await expectKnowledgeLayout(page, width);
});
}
test('cancelled and failed uploads preserve shared choices and the personal draft', async ({ page }) => {
@@ -227,15 +361,15 @@ test('cancelled and failed uploads preserve shared choices and the personal draf
await editor.getByRole('textbox', { name: '提示词', exact: true }).fill('这是我还没有保存的提示词。');
await editor.getByRole('tab', { name: '高级设置', exact: true }).click();
await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience');
await knowledge.getByText('上传自己的文件', { exact: true }).click();
await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible();
await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal');
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; });
await knowledge.getByRole('button', { name: '上传文档', exact: true }).click();
await expect(knowledge.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled();
await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click();
await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled();
await expect(knowledge.getByRole('article')).toHaveCount(0);
expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toEqual([]);
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'fail'; });
await knowledge.getByRole('button', { name: '上传文档', exact: true }).click();
await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click();
await expect(knowledge.getByRole('alert')).toContainText('上传服务暂不可用');
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturepersonal');
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识');
@@ -255,3 +389,93 @@ test('cancelled and failed uploads preserve shared choices and the personal draf
await editor.getByRole('tab', { name: '介绍与提示词', exact: true }).click();
await expect(editor.getByRole('textbox', { name: '提示词', exact: true })).toHaveValue('这是我还没有保存的提示词。');
});
test('a partial chat-file import retries only the failed file with the same operation identity', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl);
const { knowledge } = await openKnowledge(page);
await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience');
await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal');
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; });
await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click();
const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true });
await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click();
await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check();
await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check();
const confirm = picker.getByRole('button', { name: '导入 2 个文件', exact: true });
await expect(confirm).toBeInViewport();
await confirm.click({ timeout: 15_000 });
await expect(picker).toContainText('植物清单导入暂时失败');
const retry = picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true });
await expect(retry).toBeEnabled();
await expect(retry).toBeInViewport();
await expect(picker.getByRole('button', { name: '关闭文件选择', exact: true })).toBeDisabled();
await page.keyboard.press('Escape');
await expect(picker).toBeVisible();
const originalImports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment');
expect(originalImports).toHaveLength(2);
await expect(picker.getByRole('list', { name: '文件导入结果', exact: true }).getByRole('listitem').filter({ hasText: '春天观察报告.md' }))
.toContainText('已导入,处理状态见知识库');
await expectPickerBounds(page);
await page.screenshot({ path: test.info().outputPath('knowledge-chat-partial-failure-375.png'), animations: 'disabled' });
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; });
await retry.click(); await expect(picker).toHaveCount(0);
await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible();
await expect(knowledge.getByRole('article', { name: '植物清单.csv', exact: true })).toBeVisible();
const imported = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment')
.map(call => call.input?.input as { attachment_id: string; operation_id: string; kb_id: string });
expect(imported.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1);
const retried = imported.filter(input => input.attachment_id === 'attachment-plants');
expect(retried).toHaveLength(2); expect(new Set(retried.map(input => input.operation_id)).size).toBe(1);
expect(imported.every(input => input.kb_id === 'kb_fixturepersonal')).toBe(true);
const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge')
.map(call => (call.input?.input as { file_id: string }).file_id);
expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']);
expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]);
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识');
await expectNoClipping(page);
});
test('abandoning a failed batch keeps saved files and allows a fresh picker operation without replaying successes', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl);
const { knowledge } = await openKnowledge(page);
await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience');
await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal');
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; });
await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click();
const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true });
await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click();
await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check();
await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check();
await expect(picker.getByRole('button', { name: '导入 2 个文件', exact: true })).toBeInViewport();
await picker.getByRole('button', { name: '导入 2 个文件', exact: true }).click({ timeout: 15_000 });
await expect(picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true })).toBeEnabled();
const importsBefore = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment');
await picker.getByRole('button', { name: '放弃重试', exact: true }).click();
await expect(picker).toContainText('已保存的文件会保留');
const abandon = picker.getByRole('button', { name: '确认放弃重试', exact: true });
await abandon.scrollIntoViewIfNeeded(); await expect(abandon).toBeInViewport();
await expectPickerBounds(page);
await page.screenshot({ path: test.info().outputPath('knowledge-chat-abandon-confirm-375.png'), animations: 'disabled' });
await abandon.click(); await expect(picker).toHaveCount(0);
expect((await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment')).toEqual(importsBefore);
expect((await calls(page)).filter(call => /delete/i.test(String(call.input?.operation)) || call.method === 'DELETE')).toEqual([]);
await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible();
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeEnabled();
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; });
await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click();
await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click();
await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).not.toBeChecked();
await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check();
await picker.getByRole('button', { name: '导入 1 个文件', exact: true }).click();
await expect(picker).toHaveCount(0);
const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment')
.map(call => call.input?.input as { attachment_id: string; operation_id: string });
expect(imports.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1);
const plants = imports.filter(input => input.attachment_id === 'attachment-plants');
expect(plants).toHaveLength(2); expect(new Set(plants.map(input => input.operation_id)).size).toBe(2);
const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge')
.map(call => (call.input?.input as { file_id: string }).file_id);
expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']);
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识');
await expectNoClipping(page);
});
+345
View File
@@ -0,0 +1,345 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudKnowledgeAttachmentPicker } from '@/pages/CloudAgents/CloudKnowledgeAttachmentPicker';
import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge';
import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
const slug = 'ml-' + 'a'.repeat(32), otherSlug = 'ml-' + 'b'.repeat(32);
const threads: CloudThread[] = [
{ thread_id: 'spring', title: '春天的观察讨论', agent_slug: slug, client_thread_id: null, updated_at: '2026-10-13T00:00:00Z', mode: 'published', run_id: null, status: 'completed', unread: false },
{ thread_id: 'reading', title: '图书阅读记录', agent_slug: otherSlug, client_thread_id: null, updated_at: '2026-10-12T00:00:00Z', mode: 'preview', run_id: null, status: 'completed', unread: false },
];
function attachment(id: string, name: string, size = 1536): CloudAttachment {
return { file_id: id, file_name: name, file_size: size, path: '/attachments/' + name, original_path: '/attachments/' + name };
}
function knowledgeFile(id: string): CloudKnowledgeFile {
return { file_id: 'knowledge-' + id, name: id + '.txt', size: 1536, status: 'uploaded', available: false, chunk_count: 0, error: null };
}
let sourceFiles: Record<string, CloudAttachment[]>;
const imported = vi.fn<(file: CloudKnowledgeFile) => Promise<void>>();
const pending = vi.fn();
const close = vi.fn();
const props = { slug, kbId: 'kb_own', kbName: '我的观察记录', busy: false, act: async (task: () => Promise<void>) => task(), onImported: imported, onPendingChange: pending, onClose: close };
beforeEach(() => {
vi.resetAllMocks(); imported.mockResolvedValue();
sourceFiles = { spring: [attachment('a', '观察报告.md'), attachment('b', '植物资料.pdf')], reading: [attachment('a', '阅读笔记.txt')] };
api.list.mockResolvedValue({ agents: [{ slug, name: '资料小助手' }], next_cursor: null });
api.call.mockImplementation(async (operation: string, input: { thread_id?: string; attachment_id?: string }) => {
if (operation === 'threads') return { threads, next_offset: null };
if (operation === 'received') return { agents: [{ slug: otherSlug, name: '阅读搭档' }] };
if (operation === 'attachments') return { attachments: sourceFiles[input.thread_id!] };
if (operation === 'importKnowledgeAttachment') return knowledgeFile(input.attachment_id!);
throw new Error('Unexpected operation ' + operation);
});
});
afterEach(cleanup);
async function chooseChat(title = threads[0].title) {
fireEvent.click(await screen.findByRole('button', { name: '选择对话 ' + title }));
}
async function chooseFile(name: string) { fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 ' + name })); }
function imports() { return api.call.mock.calls.filter(([operation]) => operation === 'importKnowledgeAttachment').map(([, input]) => input); }
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
it('searches named and dated chats, keeps selections across chats, and imports only explicitly selected attachments', async () => {
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'threads'
? Promise.resolve(input.offset ? { threads: [threads[1]], next_offset: null } : { threads: [threads[0]], next_offset: 50 }) : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />);
expect(screen.getByRole('dialog', { name: '从聊天选择文件' })).toHaveTextContent('导入到「我的观察记录」');
await screen.findByText(/阅读搭档 · 2026/);
fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '资料小助手' } });
expect(screen.queryByRole('button', { name: '选择对话 图书阅读记录' })).not.toBeInTheDocument();
await chooseChat();
expect(await screen.findByText('MD · 2 KB')).toBeVisible();
fireEvent.change(screen.getByRole('textbox', { name: '搜索文件' }), { target: { value: '观察' } });
expect(screen.queryByRole('checkbox', { name: '选择文件 植物资料.pdf' })).not.toBeInTheDocument();
await chooseFile('观察报告.md');
fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '阅读' } });
await chooseChat(threads[1].title); await chooseFile('阅读笔记.txt');
expect(within(screen.getByRole('list', { name: '已选聊天文件' })).getAllByRole('listitem')).toHaveLength(2);
expect(imports()).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
expect(imports()).toEqual([
expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'spring', attachment_id: 'a' }),
expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'reading', attachment_id: 'a' }),
]);
expect(new Set(imports().map(input => input.operation_id)).size).toBe(2);
expect(imported).toHaveBeenCalledTimes(2);
expect(api.call.mock.calls.some(([operation]) => ['history', 'files', 'deleteAttachment'].includes(operation))).toBe(false);
expect(pending).toHaveBeenLastCalledWith(false);
});
it('retries only failed files with their original identity, retaining successful files and processing callbacks', async () => {
const original = api.call.getMockImplementation()!;
let failure = true;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' && failure
? Promise.reject(new Error('连接超时')) : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
await screen.findByText(/导入结果尚未确认:连接超时/);
expect(close).not.toHaveBeenCalled(); expect(imported).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled();
expect(screen.getByRole('button', { name: '选择对话 图书阅读记录' })).toBeDisabled();
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
expect(close).not.toHaveBeenCalled();
const first = imports();
failure = false;
fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
expect(imports()).toHaveLength(3); expect(imports()[2]).toEqual(first[1]);
expect(imported).toHaveBeenCalledTimes(2);
});
it('never reimports a confirmed file after a processing callback failure and permits a later independent batch', async () => {
imported.mockRejectedValueOnce(new Error('处理失败'));
render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText(/文件已导入,处理未全部启动/);
expect(imports()).toHaveLength(1); expect(close).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled();
await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
expect(imports().map(input => input.attachment_id)).toEqual(['a', 'b']);
});
it('shows file types and sizes, disables unsupported legacy formats and oversize files, and accepts supported files at the size boundary', async () => {
sourceFiles.spring = [attachment('legacy-doc', '旧文档.doc'), attachment('legacy-xls', '旧表格.xls'), attachment('markdown', '资料.markdown'),
attachment('image', '图片.png'), attachment('large', '过大的资料.pdf', 5 * 1024 * 1024 + 1), attachment('boundary', '可导入.DOCX', 5 * 1024 * 1024)];
render(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat();
for (const name of ['旧文档.doc', '旧表格.xls', '资料.markdown', '图片.png', '过大的资料.pdf']) expect(await screen.findByRole('checkbox', { name: '选择文件 ' + name })).toBeDisabled();
expect(screen.getAllByText('此格式暂不支持知识库导入')).toHaveLength(4);
expect(screen.getByText('超过 5 MB,暂不能导入')).toBeVisible();
expect(screen.getByText('DOCX · 5.0 MB')).toBeVisible();
await chooseFile('可导入.DOCX');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toEqual([expect.objectContaining({ attachment_id: 'boundary' })]);
});
it('distinguishes loading, empty chats, chat read failures and a successful retry', async () => {
const loading = deferred<{ threads: CloudThread[]; next_offset: null }>();
const original = api.call.getMockImplementation()!;
let mode = 'loading';
api.call.mockImplementation((operation, input) => {
if (operation !== 'threads') return original(operation, input);
return mode === 'loading' ? loading.promise : mode === 'error' ? Promise.reject(new Error('目录离线')) : Promise.resolve({ threads, next_offset: null });
});
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
expect(screen.getByText('正在读取对话…')).toBeVisible();
expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument();
await act(async () => loading.resolve({ threads: [], next_offset: null }));
expect(screen.getByText('还没有个人智能体对话。')).toBeVisible();
view.unmount(); mode = 'error'; render(<CloudKnowledgeAttachmentPicker {...props} />);
await screen.findByText('对话读取失败:目录离线');
expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument();
mode = 'ready'; fireEvent.click(screen.getByRole('button', { name: '重试读取对话' }));
await screen.findByRole('button', { name: '选择对话 春天的观察讨论' });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('distinguishes file loading, failures, empty files and search results while ignoring a late response from a previous chat', async () => {
const late = deferred<{ attachments: CloudAttachment[] }>();
const original = api.call.getMockImplementation()!;
let readingFails = true;
api.call.mockImplementation((operation, input) => operation === 'attachments'
? input.thread_id === 'spring' ? late.promise : readingFails ? Promise.reject(new Error('文件离线')) : Promise.resolve({ attachments: [] })
: original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat();
expect(screen.getByText('正在读取文件…')).toBeVisible();
expect(screen.queryByText('这段对话还没有上传文件。')).not.toBeInTheDocument();
await chooseChat(threads[1].title); await screen.findByText('文件读取失败:文件离线');
await act(async () => late.resolve({ attachments: sourceFiles.spring }));
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
readingFails = false; fireEvent.click(screen.getByRole('button', { name: '重试读取文件' }));
await screen.findByText('这段对话还没有上传文件。');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '不存在' } });
expect(screen.getByText('没有匹配的对话。')).toBeVisible();
});
it('guards X, escape and selection during an in-flight import, then closes only after all files finish', async () => {
const first = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'a' ? first.promise : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
expect(imports()).toHaveLength(1);
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled();
expect(screen.getByRole('checkbox', { name: '选择文件 观察报告.md' })).toBeDisabled();
expect(screen.queryByRole('button', { name: '放弃重试', exact: true })).not.toBeInTheDocument();
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
expect(close).not.toHaveBeenCalled();
await act(async () => first.resolve(knowledgeFile('a')));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toHaveLength(2); expect(imported).toHaveBeenCalledTimes(2);
});
it('does not read or import without management permission and resumes a confirmed receipt without importing it twice', async () => {
const receipt = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? receipt.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} canRead={false} />);
expect(api.call).not.toHaveBeenCalled(); expect(api.list).not.toHaveBeenCalled();
view.rerender(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
view.rerender(<CloudKnowledgeAttachmentPicker {...props} canRead={false} />);
await act(async () => receipt.resolve(knowledgeFile('a')));
expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(pending).toHaveBeenLastCalledWith(true);
view.rerender(<CloudKnowledgeAttachmentPicker {...props} />);
fireEvent.click(screen.getByRole('button', { name: '继续导入 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toHaveLength(1); expect(imported).toHaveBeenCalledOnce();
});
it('ignores an old-target response and stops the old batch after a knowledge-base scope change', async () => {
const response = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
view.rerender(<CloudKnowledgeAttachmentPicker {...props} kbId="kb_other" kbName="其他资料" />);
await act(async () => response.resolve(knowledgeFile('a')));
expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toHaveTextContent('导入到「其他资料」');
expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument();
});
it('does not continue a batch or notify the old owner after unmount', async () => {
const response = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); view.unmount();
await act(async () => response.resolve(knowledgeFile('a')));
expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled();
expect(pending).toHaveBeenLastCalledWith(false);
});
it('hides the portal after a confirmed loss of ownership so the parent can discard pending requests without importing again', async () => {
const response = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
let owned = true;
api.call.mockImplementation((operation, input) => {
if (operation === 'knowledge') return Promise.resolve({ databases: owned ? [{ kb_id: 'kb_own', name: '我的观察记录' }] : [], models: [] });
if (operation === 'knowledgeFiles') return Promise.resolve({ files: [], next_offset: null });
if (operation === 'importKnowledgeAttachment') return response.promise;
return original(operation, input);
});
const created = vi.fn();
const view = render(<CloudKnowledgePanel slug={slug} onCreated={created} />);
await screen.findByRole('option', { name: '我的观察记录' });
fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: 'kb_own' } });
fireEvent.click(await screen.findByRole('button', { name: '从聊天选择文件' }));
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
owned = false;
view.rerender(<CloudKnowledgePanel slug={slug} onCreated={created} refreshToken={1} />);
await waitFor(() => expect(screen.queryByRole('dialog', { name: '从聊天选择文件' })).not.toBeInTheDocument());
await act(async () => response.resolve(knowledgeFile('a')));
const discard = await screen.findByRole('button', { name: '放弃未确认操作并重新选择' });
await waitFor(() => expect(discard).toBeEnabled());
expect(imports()).toHaveLength(1);
expect(api.call.mock.calls.some(([operation]) => operation === 'processKnowledge')).toBe(false);
fireEvent.click(discard);
expect(screen.queryByRole('button', { name: '放弃未确认操作并重新选择' })).not.toBeInTheDocument();
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue('');
});
it('allows cancelling unsubmitted selections and does not mutate knowledge merely by choosing files', async () => {
render(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat(); await chooseFile('观察报告.md');
expect(pending).toHaveBeenLastCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' }));
expect(close).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(0); expect(imported).not.toHaveBeenCalled();
});
it.each(['button', 'escape'])('allows %s dismissal during a catalog failure before any import has been sent', async control => {
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md');
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy />);
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled();
if (control === 'button') fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' }));
else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toHaveLength(0);
});
it('offers a read-only catalog refresh while an uncertain import is blocked, then retries only the original operation', async () => {
const original = api.call.getMockImplementation()!;
let fails = true;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && fails
? Promise.reject(new Error('结果未确认')) : original(operation, input));
const refresh = vi.fn();
const view = render(<CloudKnowledgeAttachmentPicker {...props} onRefreshKnowledge={refresh} />);
await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText(/导入结果尚未确认:结果未确认/);
const request = imports()[0];
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy onRefreshKnowledge={refresh} />);
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled();
expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true }));
expect(refresh).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(1);
fails = false;
view.rerender(<CloudKnowledgeAttachmentPicker {...props} onRefreshKnowledge={refresh} />);
fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toEqual([request, request]); expect(imported).toHaveBeenCalledOnce();
});
it('requires explicit confirmation to abandon a retry during a catalog failure, while keeping the request through cancelling that confirmation', async () => {
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment'
? Promise.reject(new Error('附件已无法读取')) : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText(/导入结果尚未确认:附件已无法读取/);
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy />);
fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true }));
expect(screen.getByText('这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。')).toBeVisible();
expect(close).not.toHaveBeenCalled(); expect(pending).toHaveBeenLastCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: '继续保留' }));
expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled();
expect(screen.getByRole('list', { name: '文件导入结果' })).toHaveTextContent('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' }));
expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false);
expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled();
expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument();
expect(screen.queryByRole('list', { name: '已选聊天文件' })).not.toBeInTheDocument();
});
it('exits a partly successful batch with a permanent source error without deleting or replaying successful imports', async () => {
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b'
? Promise.reject(new Error('原附件已经删除')) : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
await screen.findByText(/导入结果尚未确认:原附件已经删除/);
expect(imported).toHaveBeenCalledOnce();
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled();
expect(screen.queryByText(/可继续切换对话选择/)).not.toBeInTheDocument();
const before = api.call.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' }));
expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false);
expect(imported).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(2);
expect(api.call).toHaveBeenCalledTimes(before);
});
+9 -9
View File
@@ -63,7 +63,7 @@ it('never offers shared libraries to document management or mounts their mutatin
await screen.findByText('MakeLore知识 · 只读'); openUpload();
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
expect(within(manager).queryByRole('option', { name: '创作指南' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '上传文档', exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '删除知识库', exact: true })).not.toBeInTheDocument();
expect(api.call.mock.calls.every(([operation]) => operation !== 'knowledgeFiles')).toBe(true);
});
@@ -78,7 +78,7 @@ it('allows readable binding after an owned-list failure while withholding docume
expect(screen.queryByRole('group', { name: 'MakeLore知识' })).not.toBeInTheDocument();
choose('shared'); openUpload();
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled();
expect(screen.queryByRole('button', { name: '上传文档', exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
failed = false;
fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true }));
await screen.findByText('MakeLore知识 · 只读');
@@ -116,9 +116,9 @@ it('selects an existing owned library for uploads and cancelling a file picker d
await openOwn();
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] }));
api.uploadKnowledge.mockResolvedValue(null);
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true }));
await waitFor(() => expect(api.uploadKnowledge).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled());
await waitFor(() => expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled());
expect(api.call.mock.calls.every(([operation]) => operation !== 'processKnowledge')).toBe(true);
expect(screen.getByRole('button', { name: '移除知识库 创作指南' })).toBeVisible();
});
@@ -180,7 +180,7 @@ it('keeps an uncertain upload scoped to its library through retry cancellation a
const pending: Record<string, boolean> = {};
render(<CloudPendingContext.Provider value={(id, value) => { pending[id] = value; }}><Harness initial={['shared']} /></CloudPendingContext.Provider>);
await openOwn();
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true }));
await screen.findByText('上传结果未确认');
expect(Object.values(pending).some(Boolean)).toBe(true);
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
@@ -224,7 +224,7 @@ it('preserves uncertain processing while refreshing and releases it when the ser
it('retains unconfirmed operations in a disabled manager if its owned library disappears and requires explicit discard', async () => {
api.uploadKnowledge.mockRejectedValue(new Error('上传结果未确认'));
render(<Harness initial={['shared']} />); await openOwn();
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true }));
await screen.findByText('上传结果未确认');
const before = api.call.mock.calls.filter(([operation]) => operation === 'knowledgeFiles').length;
databases = []; fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' }));
@@ -249,7 +249,7 @@ it('automatically processes a confirmed upload that finishes while both knowledg
});
api.uploadKnowledge.mockImplementation(() => new Promise(value => { finishUpload = value; }));
render(<Harness />); await openOwn();
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true }));
holdRefresh = true; fireEvent.click(screen.getByRole('button', { name: '刷新目录' }));
await screen.findByRole('option', { name: '正在读取我的知识库…' });
await act(async () => finishUpload({ file_id: 'confirmed', name: '资料.txt', size: 10, status: 'uploaded', chunk_count: 0, available: false, error: null }));
@@ -270,7 +270,7 @@ it('finishes an authorized upload through a failed ownership refresh and does no
});
api.uploadKnowledge.mockImplementation(() => new Promise(value => { finishUpload = value; }));
render(<Harness />); await openOwn();
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true }));
holdRefresh = true; fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true }));
await screen.findByRole('option', { name: '正在读取我的知识库…' });
await act(async () => rejectRefresh(new Error('目录连接失败')));
@@ -316,6 +316,6 @@ it('keeps an owned but unusable library manageable without adding an invalid bin
await screen.findByText('上传第一份文档,让智能体用你的资料回答。');
expect(screen.getByText('这个知识库当前暂不可选用。你仍可管理自己的文件,刷新目录后再试。')).toBeVisible();
expect(changes).toHaveBeenCalledTimes(before);
expect(screen.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled();
expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled();
expect(changes.mock.calls.at(-1)?.[0].knowledges).toEqual(['shared']);
});
+9 -9
View File
@@ -3,13 +3,15 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge';
import type { CloudKnowledgeFile } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ call: vi.fn(), uploadKnowledge: vi.fn() }));
const api = vi.hoisted(() => ({ call: vi.fn(), uploadKnowledge: vi.fn(), list: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
const file: CloudKnowledgeFile = { file_id: 'file', name: '学习路线图.pdf', size: 3000, status: 'uploaded', chunk_count: 0, error: null, available: false };
let items: CloudKnowledgeFile[];
beforeEach(() => {
vi.resetAllMocks(); items = [];
api.list.mockResolvedValue({ agents: [], next_cursor: null });
api.call.mockImplementation(async operation => {
if (operation === 'received') return { agents: [] };
if (operation === 'knowledge') return { databases: [{ kb_id: 'kb', name: '资料' }, { kb_id: 'other', name: '其他库' }], models: [] };
if (operation === 'knowledgeFiles') return { files: items, next_offset: null };
if (operation === 'processKnowledge') {
@@ -33,7 +35,7 @@ it('uploads then processes automatically and refreshes through queue, parse, ind
await screen.findByText('上传第一份文档,让智能体用你的资料回答。');
vi.useFakeTimers();
api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); });
expect(screen.getByText('等待处理')).toBeVisible();
expect(screen.queryByText(/0 个分块/)).not.toBeInTheDocument();
expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: 'kb', file_id: 'file' }));
@@ -74,7 +76,7 @@ it('retains the saved upload and processing identity after an uncertain submissi
});
api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; });
await open();
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true }));
expect(await screen.findByRole('alert')).toHaveTextContent('文件已保存,处理请求尚未确认');
expect(screen.getByRole('article', { name: file.name })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '重试处理' }));
@@ -169,12 +171,10 @@ it('imports an attachment and processes it without claiming immediate readiness'
return original(operation, input);
});
await open();
fireEvent.click(screen.getByRole('button', { name: '从对话附件导入' }));
await screen.findByRole('option', { name: '我的对话' });
fireEvent.change(screen.getByLabelText('附件来源对话'), { target: { value: 'thread' } });
await screen.findByRole('option', { name: '附件.pdf' });
fireEvent.change(screen.getByLabelText('入库附件'), { target: { value: 'attachment' } });
fireEvent.click(screen.getByRole('button', { name: '确认导入知识库' }));
fireEvent.click(screen.getByRole('button', { name: '从聊天选择文件' }));
fireEvent.click(await screen.findByRole('button', { name: '选择对话 我的对话' }));
fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 附件.pdf' }));
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText('等待处理');
expect(screen.queryByText('可用于回答')).not.toBeInTheDocument();
});