fix(knowledge): keep computer upload and place creation below it

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 13:39:30 +08:00
1 parent a6126c0ae2
commit a589a864d7
11 files changed
+97 -944

No files matched your search

+18 -186
View File
@@ -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<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, 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);
});