setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'}
{active ? '处理中 · 状态自动更新' : '状态自动更新'}
diff --git a/src/pages/CloudAgents/agent-config.css b/src/pages/CloudAgents/agent-config.css
index 4995f0b9..16b6826b 100644
--- a/src/pages/CloudAgents/agent-config.css
+++ b/src/pages/CloudAgents/agent-config.css
@@ -55,51 +55,6 @@
.agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; }
.agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; }
.agent-knowledge-selection button { flex-shrink: 0; }
-.agent-knowledge-import.agent-knowledge-import-modal { display: flex; flex-direction: column; width: calc(100vw - 32px); max-width: 860px; max-height: calc(100dvh - 32px); gap: 18px; border-radius: 22px; padding: 24px; background: #fffdf9; color: #273246; }
-.agent-knowledge-import-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-shrink: 0; min-width: 0; }
-.agent-knowledge-import-header > div { min-width: 0; overflow-wrap: anywhere; }
-.agent-knowledge-import-header p { margin-top: 6px; line-height: 1.7; color: #59657a; }
-.agent-knowledge-import-content { display: flex; flex-direction: column; gap: 16px; min-height: 0; min-width: 0; overflow: auto; overscroll-behavior: contain; }
-.agent-knowledge-import-help { color: #606c7e; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
-.agent-knowledge-import-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 16px; min-height: 0; min-width: 0; overflow: auto; }
-.agent-knowledge-import-content > .agent-knowledge-import-body { flex-shrink: 0; }
-.agent-knowledge-import-chats, .agent-knowledge-import-files { display: flex; flex-direction: column; min-width: 0; min-height: 0; gap: 10px; }
-.agent-knowledge-import-chats h3, .agent-knowledge-import-files h3 { font-size: 14px; font-weight: 600; }
-.agent-knowledge-import-search { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606c7e; }
-.agent-knowledge-import-search svg, .agent-knowledge-import-file > svg { flex-shrink: 0; }
-.agent-knowledge-import :is(input:not([type="checkbox"]), select) { width: 100%; min-width: 0; min-height: 42px; border: 1px solid #cdd5e3; border-radius: 11px; background: #fff; padding: 10px 12px; font-size: 14px; }
-.agent-knowledge-import :is(input, button):focus-visible { outline: 2px solid #9a7bb5; outline-offset: 2px; }
-.agent-knowledge-import-chat-list, .agent-knowledge-import-file-list { min-width: 0; overflow: auto; max-height: 330px; overscroll-behavior: contain; border: 1px solid #dce2ec; border-radius: 13px; background: #fff; }
-.agent-knowledge-import-chat-list > :is(p, div), .agent-knowledge-import-file-list > :is(p, div) { padding: 14px; color: #606c7e; font-size: 13px; line-height: 1.7; }
-.agent-knowledge-import-chat { display: flex; flex-direction: column; gap: 5px; width: 100%; min-height: 68px; padding: 12px; text-align: left; font-size: 14px; line-height: 1.5; cursor: pointer; overflow-wrap: anywhere; transition: background-color 160ms ease; }
-.agent-knowledge-import-chat + .agent-knowledge-import-chat { border-top: 1px solid #edf0f5; }
-.agent-knowledge-import-chat:hover { background: #f6f5fa; }
-.agent-knowledge-import-chat[aria-pressed="true"] { background: #f1f6ff; box-shadow: inset 3px 0 #6b87b3; }
-.agent-knowledge-import-chat:disabled { cursor: not-allowed; }
-.agent-knowledge-import-chat small { color: #606c7e; font-size: 12px; }
-.agent-knowledge-import-file { display: flex; align-items: flex-start; gap: 10px; min-width: 0; min-height: 64px; padding: 12px; font-size: 14px; line-height: 1.6; cursor: pointer; overflow-wrap: anywhere; }
-.agent-knowledge-import-file + .agent-knowledge-import-file { border-top: 1px solid #edf0f5; }
-.agent-knowledge-import-file:has(input:checked) { background: #f6f2ff; }
-.agent-knowledge-import-file:has(input:disabled) { cursor: not-allowed; }
-.agent-knowledge-import-file input[type="checkbox"] { width: 17px; height: 17px; margin-top: 3px; accent-color: #79629d; flex-shrink: 0; }
-.agent-knowledge-import-file > span { min-width: 0; }
-.agent-knowledge-import-file small { display: block; font-size: 12px; color: #606c7e; }
-.agent-knowledge-import-selection { flex-shrink: 0; min-width: 0; border-radius: 12px; background: #f3f0f8; padding: 12px; font-size: 13px; overflow-wrap: anywhere; }
-.agent-knowledge-import-selection ul { max-height: 130px; overflow: auto; }
-.agent-knowledge-import-selection li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 8px; }
-.agent-knowledge-import-selection li > span { min-width: 0; }
-.agent-knowledge-import-selection li button { flex-shrink: 0; }
-.agent-knowledge-import-selection small { display: block; color: #606c7e; font-size: 12px; }
-.agent-knowledge-import-results { max-height: 170px; min-height: 0; flex-shrink: 0; overflow: auto; border: 1px solid #dce5e0; border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
-.agent-knowledge-import-results li + li { border-top: 1px solid #e8e6e1; margin-top: 10px; padding-top: 10px; }
-.agent-knowledge-import-results li > span { display: flex; align-items: center; gap: 6px; }
-.agent-knowledge-import-results li > span + span { color: #526c5f; }
-.agent-knowledge-import-results [role="alert"] { color: #b54532; }
-.agent-knowledge-import-abandon { flex-shrink: 0; padding: 12px; border: 1px solid #efd9ba; border-radius: 12px; background: #fff5e9; font-size: 13px; line-height: 1.7; }
-.agent-knowledge-import-abandon p { margin-bottom: 10px; }
-.agent-knowledge-import-abandon button { margin: 0 8px 6px 0; }
-.agent-knowledge-import-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 9px; flex-shrink: 0; }
-.agent-knowledge-import-footer p { flex: 1 1 280px; min-width: 0; font-size: 12px; line-height: 1.7; color: #606c7e; }
@container (max-width: 620px) { .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 640px) {
.agent-editor-tabs { padding: 10px 12px; }
@@ -114,10 +69,5 @@
.agent-config-section-top > button { margin-left: 48px; }
.agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; }
.agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; }
- .agent-knowledge-import.agent-knowledge-import-modal { padding: 18px; border-radius: 18px; gap: 14px; }
- .agent-knowledge-import-body { grid-template-columns: minmax(0, 1fr); }
- .agent-knowledge-import-chat-list { max-height: 160px; }
- .agent-knowledge-import-file-list { max-height: 240px; }
- .agent-knowledge-import-footer button { width: 100%; }
}
-@media (prefers-reduced-motion: reduce) { .agent-resource-option, .agent-knowledge-import-chat { transition: none; } }
+@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } }
diff --git a/tests/e2e/cloud-agent-knowledge.spec.ts b/tests/e2e/cloud-agent-knowledge.spec.ts
index f1007d28..1e3f83b1 100644
--- a/tests/e2e/cloud-agent-knowledge.spec.ts
+++ b/tests/e2e/cloud-agent-knowledge.spec.ts
@@ -5,7 +5,7 @@ import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/pro
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';
+import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile } from '../../shared/cloud-agents';
let server: Server;
let baseUrl: string;
@@ -82,37 +82,20 @@ 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 = {
- '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 | null };
type KnowledgeFixture = {
agent: CloudAgentDraft; libraries: CloudKnowledge[]; files: Record;
calls: FixtureCall[]; ownedIds: string[]; upload: 'success' | 'cancel' | 'fail'; processFailure: boolean;
- threads: CloudThread[]; attachments: Record; importFailures: string[];
};
type FixtureWindow = Window & { __knowledgeFixture: KnowledgeFixture };
async function installFixture(page: Page) {
page.on('pageerror', error => { throw error; });
- await page.addInitScript(({ agent, libraries, sourceThreads, sourceAttachments }) => {
+ await page.addInitScript(({ agent, libraries }) => {
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));
@@ -158,19 +141,7 @@ 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('处理请求暂不可用');
@@ -183,7 +154,7 @@ async function installFixture(page: Page) {
}
},
} } });
- }, { agent, libraries, sourceThreads, sourceAttachments });
+ }, { agent, libraries });
}
async function openKnowledge(page: Page) {
@@ -222,22 +193,6 @@ async function expectKnowledgeLayout(page: Page, width: number) {
}
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 }) => {
@@ -259,7 +214,7 @@ for (const width of [375, 1440]) {
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 expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
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();
@@ -276,12 +231,25 @@ for (const width of [375, 1440]) {
await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience');
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();
+ const upload = knowledge.getByRole('button', { name: '从电脑上传', exact: true });
+ const create = knowledge.getByRole('button', { name: '新建知识库', exact: true });
+ await expect(upload).toBeDisabled();
+ const uploadBounds = await upload.boundingBox();
+ const createBounds = await create.boundingBox();
+ expect(uploadBounds).not.toBeNull(); expect(createBounds).not.toBeNull();
+ expect(createBounds!.y).toBeGreaterThan(uploadBounds!.y + uploadBounds!.height);
+ await create.click();
await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料');
await knowledge.getByRole('combobox', { name: '向量模型', exact: true }).selectOption('fixture-embedding');
await knowledge.getByRole('button', { name: '创建并选用', exact: true }).click();
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated');
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料');
+ await expect(upload).toBeEnabled();
+ const selectedUploadBounds = await upload.boundingBox();
+ const selectedCreateBounds = await create.boundingBox();
+ expect(selectedUploadBounds).not.toBeNull(); expect(selectedCreateBounds).not.toBeNull();
+ expect(selectedCreateBounds!.y).toBeGreaterThan(selectedUploadBounds!.y + selectedUploadBounds!.height);
+ await expect(knowledge.getByRole('button', { name: '从聊天选择文件', exact: true })).toHaveCount(0);
expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]);
await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click();
const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true });
@@ -306,52 +274,6 @@ 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 }) => {
@@ -389,93 +311,3 @@ 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);
-});
diff --git a/tests/unit/cloud-knowledge-catalog.test.tsx b/tests/unit/cloud-knowledge-catalog.test.tsx
index 0c740867..38c4073a 100644
--- a/tests/unit/cloud-knowledge-catalog.test.tsx
+++ b/tests/unit/cloud-knowledge-catalog.test.tsx
@@ -20,6 +20,7 @@ function openCreation() {
it('explains who configures an empty embedding catalog and refreshes without losing the name', async () => {
api.call.mockResolvedValueOnce(empty).mockResolvedValueOnce(available);
openCreation();
+ expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
expect(await screen.findByText('暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。')).toBeVisible();
expect(screen.getByRole('combobox', { name: '向量模型' })).toBeDisabled();
expect(screen.getByText(/上传到知识库的文档可供/)).toHaveTextContent('embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限');
diff --git a/tests/unit/cloud-knowledge-import.test.tsx b/tests/unit/cloud-knowledge-import.test.tsx
deleted file mode 100644
index 8b93b62a..00000000
--- a/tests/unit/cloud-knowledge-import.test.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-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;
-const imported = vi.fn<(file: CloudKnowledgeFile) => Promise>();
-const pending = vi.fn();
-const close = vi.fn();
-const props = { slug, kbId: 'kb_own', kbName: '我的观察记录', busy: false, act: async (task: () => Promise) => 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() {
- let resolve!: (value: T) => void;
- let reject!: (error: Error) => void;
- const promise = new Promise((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();
- 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();
- 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();
- 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(); 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();
- 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();
- 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(); 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();
- const original = api.call.getMockImplementation()!;
- api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'a' ? first.promise : original(operation, input));
- render(); 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();
- const original = api.call.getMockImplementation()!;
- api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? receipt.promise : original(operation, input));
- const view = render();
- expect(api.call).not.toHaveBeenCalled(); expect(api.list).not.toHaveBeenCalled();
- view.rerender(); await chooseChat(); await chooseFile('观察报告.md');
- fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
- view.rerender();
- 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();
- 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();
- const original = api.call.getMockImplementation()!;
- api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input));
- const view = render();
- await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
- fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
- view.rerender();
- 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();
- const original = api.call.getMockImplementation()!;
- api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input));
- const view = render();
- 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();
- 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();
- 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();
- 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(); 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();
- await chooseChat(); await chooseFile('观察报告.md');
- view.rerender();
- 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();
- await chooseChat(); await chooseFile('观察报告.md');
- fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
- await screen.findByText(/导入结果尚未确认:结果未确认/);
- const request = imports()[0];
- view.rerender();
- 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();
- 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();
- await chooseChat(); await chooseFile('观察报告.md');
- fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
- await screen.findByText(/导入结果尚未确认:附件已无法读取/);
- view.rerender();
- 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();
- 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);
-});
diff --git a/tests/unit/cloud-knowledge-picker.test.tsx b/tests/unit/cloud-knowledge-picker.test.tsx
index e83b2689..48284f34 100644
--- a/tests/unit/cloud-knowledge-picker.test.tsx
+++ b/tests/unit/cloud-knowledge-picker.test.tsx
@@ -57,13 +57,13 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['own', 'other-shared'] }));
});
-it('never offers shared libraries to document management or mounts their mutating controls', async () => {
+it('never offers shared libraries to document management or enables their upload controls', async () => {
databases = [];
render();
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.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
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.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
failed = false;
fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true }));
await screen.findByText('MakeLore知识 · 只读');
diff --git a/tests/unit/cloud-knowledge-status.test.tsx b/tests/unit/cloud-knowledge-status.test.tsx
index 2b029716..4b442636 100644
--- a/tests/unit/cloud-knowledge-status.test.tsx
+++ b/tests/unit/cloud-knowledge-status.test.tsx
@@ -33,6 +33,7 @@ async function tick() { await act(async () => { await vi.advanceTimersByTimeAsyn
it('uploads then processes automatically and refreshes through queue, parse, index and readiness', async () => {
await open();
await screen.findByText('上传第一份文档,让智能体用你的资料回答。');
+ expect(screen.queryByRole('button', { name: '从聊天选择文件' })).not.toBeInTheDocument();
vi.useFakeTimers();
api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); });
@@ -48,6 +49,7 @@ it('uploads then processes automatically and refreshes through queue, parse, ind
expect(screen.getByText('可用于回答')).toBeVisible();
expect(screen.getByText(/12 段可检索内容/)).toBeVisible();
expect(api.uploadKnowledge).toHaveBeenCalledTimes(1);
+ expect(api.call.mock.calls.some(([operation]) => ['threads', 'attachments', 'importKnowledgeAttachment'].includes(operation))).toBe(false);
});
it('does not call zero chunks searchable and distinguishes loading from an empty library', async () => {
@@ -162,23 +164,6 @@ it('keeps all loaded pages refreshing and ignores a late response from the previ
expect(screen.getByText('上传第一份文档,让智能体用你的资料回答。')).toBeVisible();
});
-it('imports an attachment and processes it without claiming immediate readiness', async () => {
- const original = api.call.getMockImplementation()!;
- api.call.mockImplementation(async (operation, input) => {
- if (operation === 'threads') return { threads: [{ thread_id: 'thread', title: '我的对话' }], next_offset: null };
- if (operation === 'attachments') return { attachments: [{ file_id: 'attachment', file_name: '附件.pdf' }] };
- if (operation === 'importKnowledgeAttachment') { items = [file]; return file; }
- return original(operation, input);
- });
- await open();
- 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();
-});
-
it('keeps indexed content available when replacement cleanup fails and permits retry', async () => {
items = [{ ...file, status: 'indexed', chunk_count: 4, available: true, replaces_file_id: 'old',
processing_task: { task_id: 'task', status: 'failed', error: '旧文档清理失败' } }];