feat: paste local files into chat attachments
Copied local files previously required the attachment picker. The chat composer now imports one or more supported files directly from paste, sharing parsing progress, limits, and batch rollback with file selection. Text and screenshot paste remain available, and failures preserve drafts. Validation: prior 166 focused tests, typecheck, and lint passed; 28 targeted tests passed again before commit. The prior full suite had 4214 passes, 67 skips, and 6 window-recovery failures; window tests passed 10/10 alone. Native clipboard behavior was verified on Windows; macOS/Linux remain unverified. Release note: 现在可在文件管理器中复制一个或多个支持的本地文件,直接粘贴到聊天输入框添加附件;可查看读取和解析进度,失败时保留草稿与已有附件。
This commit is contained in:
@@ -39,6 +39,68 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ContextManager', () => {
|
||||
it('imports multiple pasted paths with real document parsing and request enrichment', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-pasted-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const paths = [join(directory, 'notes.txt'), join(directory, 'report.docx')]
|
||||
await writeFile(paths[0]!, 'PASTED_TEXT')
|
||||
await writeFile(paths[1]!, zipSync({
|
||||
'word/document.xml': strToU8('<w:document><w:p><w:t>PASTED_DOCX</w:t></w:p></w:document>')
|
||||
}))
|
||||
const manager = new ContextManager()
|
||||
const progress = vi.fn()
|
||||
const attachments = await manager.importFiles(paths, progress)
|
||||
expect(attachments.map(item => item.name)).toEqual(['notes.txt', 'report.docx'])
|
||||
expect(showOpenDialog).not.toHaveBeenCalled()
|
||||
expect(progress.mock.calls.map(([value]) => value)).toEqual([
|
||||
{ phase: 'reading', fileName: 'notes.txt', fileNumber: 1, fileCount: 2 },
|
||||
{ phase: 'reading', fileName: 'report.docx', fileNumber: 2, fileCount: 2 },
|
||||
{ phase: 'parsing', fileName: 'report.docx', fileNumber: 2, fileCount: 2 }
|
||||
])
|
||||
const request = manager.enrichRequest({
|
||||
requestId: crypto.randomUUID(), conversationId: 'pasted-files', prompt: 'Read',
|
||||
contextIds: attachments.map(item => item.id)
|
||||
})
|
||||
expect(request.prompt).toContain('PASTED_TEXT')
|
||||
expect(request.prompt).toContain('PASTED_DOCX')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unsupported.zip', 1, /不支持的文件类型/],
|
||||
['large.txt', 256 * 1024 + 1, /256KB/],
|
||||
['large.pdf', 20 * 1024 * 1024 + 1, /20MB/],
|
||||
['large.png', 12 * 1024 * 1024 + 1, /12MB/]
|
||||
] as const)('rolls back a pasted batch containing %s and preserves earlier attachments', async (name, size, error) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-pasted-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const good = join(directory, 'notes.txt')
|
||||
const bad = join(directory, name)
|
||||
await writeFile(good, 'Retained text')
|
||||
await writeFile(bad, Buffer.alloc(size))
|
||||
const manager = new ContextManager()
|
||||
const [existing] = await manager.importFiles([good])
|
||||
const remove = vi.spyOn(manager, 'remove')
|
||||
await expect(manager.importFiles([good, bad])).rejects.toThrow(error)
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
expect(remove).not.toHaveBeenCalledWith(existing!.id)
|
||||
expect(manager.enrichRequest({
|
||||
requestId: crypto.randomUUID(), conversationId: 'pasted-files', prompt: 'Read',
|
||||
contextIds: [existing!.id]
|
||||
}).prompt).toContain('Retained text')
|
||||
})
|
||||
|
||||
it('uses the upload batch cap and hides filesystem paths in paste failures', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-pasted-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'notes.txt')
|
||||
await writeFile(path, 'Text')
|
||||
const manager = new ContextManager()
|
||||
expect(await manager.importFiles(Array(9).fill(path))).toHaveLength(8)
|
||||
await expect(manager.importFiles([join(directory, 'missing.txt')])).rejects.toThrow(
|
||||
'无法读取所选文件,请检查文件权限和状态'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the tail of a real DOCX larger than the former extracted-text cap', async () => {
|
||||
const content = `${'word text '.repeat(30_000)}DOCX_TAIL`
|
||||
const data = Buffer.from(zipSync({
|
||||
|
||||
@@ -300,8 +300,15 @@ export class ContextManager {
|
||||
return []
|
||||
}
|
||||
|
||||
return this.importFiles(result.filePaths, onProgress)
|
||||
}
|
||||
|
||||
async importFiles(
|
||||
paths: string[],
|
||||
onProgress?: (progress: ContextFileSelectionProgress) => void
|
||||
): Promise<ContextAttachment[]> {
|
||||
const attachments: ContextAttachment[] = []
|
||||
const selectedPaths = result.filePaths.slice(
|
||||
const selectedPaths = paths.slice(
|
||||
0,
|
||||
maximumAttachmentsPerMessage
|
||||
)
|
||||
|
||||
@@ -531,7 +531,7 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
'CommandOrControl+Shift+Space',
|
||||
{} as never,
|
||||
capabilityService as never,
|
||||
{ clear: vi.fn(), selectFiles } as never,
|
||||
{ clear: vi.fn(), selectFiles, importFiles: selectFiles } as never,
|
||||
{} as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
@@ -616,6 +616,11 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
electronMocks.handlers.get(ipcChannels.contextSelectFiles)?.(event)
|
||||
).resolves.toEqual([])
|
||||
expect(selectFiles).toHaveBeenCalledWith(window, expect.any(Function))
|
||||
const importFiles = electronMocks.handlers.get(ipcChannels.contextImportFiles)!
|
||||
await expect(importFiles(event, { paths: ['C:\\scan.pdf'] })).resolves.toEqual([])
|
||||
expect(selectFiles).toHaveBeenCalledWith(['C:\\scan.pdf'], expect.any(Function))
|
||||
expect(() => importFiles(event, { paths: [''] })).toThrow()
|
||||
expect(() => importFiles({ ...event, senderFrame: {} }, { paths: ['C:\\scan.pdf'] })).toThrow()
|
||||
expect(webContents.send).toHaveBeenCalledWith(
|
||||
ipcChannels.contextFileSelectionProgress,
|
||||
{
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
browserStopRequestSchema,
|
||||
browserTypeRequestSchema,
|
||||
clipboardTextSchema,
|
||||
contextImportFilesSchema,
|
||||
conversationQueueUserInputSchema,
|
||||
defaultRuntimeSettings,
|
||||
knowledgeCreateSchema,
|
||||
@@ -7570,6 +7571,16 @@ export function registerIpcHandlers(
|
||||
})
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.contextImportFiles, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { paths } = contextImportFilesSchema.parse(input)
|
||||
return contextManager.importFiles(paths, (progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send(ipcChannels.contextFileSelectionProgress, progress)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.contextAddPastedImage,
|
||||
(event, input: unknown) => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DesktopApi } from '../shared/contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
contextBridge: { exposeInMainWorld: vi.fn() },
|
||||
ipcRenderer: { invoke: vi.fn(async () => []) },
|
||||
webUtils: { getPathForFile: vi.fn(() => 'C:\\notes.txt') }
|
||||
}))
|
||||
vi.mock('electron', () => electron)
|
||||
|
||||
describe('context file preload bridge', () => {
|
||||
it('resolves native File paths and sends only paths to Main', async () => {
|
||||
await import('./index')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
|
||||
const file = {} as File
|
||||
const path = api.context.getFilePath(file)
|
||||
expect(electron.webUtils.getPathForFile).toHaveBeenCalledExactlyOnceWith(file)
|
||||
await expect(api.context.importFiles([path])).resolves.toEqual([])
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(
|
||||
ipcChannels.contextImportFiles, { paths: ['C:\\notes.txt'] }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1486,6 +1486,12 @@ const desktopApi: DesktopApi = {
|
||||
) as Promise<RuntimeNativeSnapshot>
|
||||
},
|
||||
context: {
|
||||
getFilePath: (file) => webUtils.getPathForFile(file),
|
||||
importFiles: (paths) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.contextImportFiles,
|
||||
{ paths }
|
||||
) as Promise<ContextAttachment[]>,
|
||||
selectFiles: () =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.contextSelectFiles
|
||||
|
||||
@@ -753,6 +753,8 @@ const api: DesktopApi = {
|
||||
})),
|
||||
},
|
||||
context: {
|
||||
getFilePath: vi.fn(() => ""),
|
||||
importFiles: vi.fn(async () => []),
|
||||
selectFiles: vi.fn(async () => []),
|
||||
onFileSelectionProgress: vi.fn((listener) => {
|
||||
fileSelectionProgressListener = listener;
|
||||
@@ -1586,11 +1588,11 @@ describe("App", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text",
|
||||
"Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text",
|
||||
);
|
||||
expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
|
||||
"title",
|
||||
"Enter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text",
|
||||
"Enter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text",
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -5776,7 +5778,7 @@ describe("App", () => {
|
||||
name: "附件读取与解析进度",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("正在选择附件…")).toBeInTheDocument();
|
||||
expect(screen.getByText("正在添加附件…")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fileSelectionProgressListener?.({
|
||||
@@ -5875,6 +5877,63 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("imports copied files with progress, prevents duplicate pastes, and sends their context", async () => {
|
||||
const files = [new File(["notes"], "notes.txt"), new File(["doc"], "report.docx")];
|
||||
vi.mocked(api.context.getFilePath)
|
||||
.mockReturnValueOnce("C:\\notes.txt")
|
||||
.mockReturnValueOnce("C:\\report.docx");
|
||||
const attachments: ContextAttachment[] = files.map((file, index) => ({
|
||||
id: `00000000-0000-4000-8000-00000000032${index}`,
|
||||
name: file.name, kind: "text", size: 4, preview: "parsed content",
|
||||
}));
|
||||
let finish: ((attachments: ContextAttachment[]) => void) | undefined;
|
||||
vi.mocked(api.context.importFiles).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
render(<App />);
|
||||
const input = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
fireEvent.change(input, { target: { value: "Read these files" } });
|
||||
expect(fireEvent.paste(input, { clipboardData: { files } })).toBe(false);
|
||||
expect(api.context.importFiles).toHaveBeenCalledExactlyOnceWith(["C:\\notes.txt", "C:\\report.docx"]);
|
||||
expect(screen.getByLabelText("添加附件")).toBeDisabled();
|
||||
act(() => fileSelectionProgressListener?.({
|
||||
phase: "parsing", fileName: "report.docx", fileNumber: 2, fileCount: 2,
|
||||
}));
|
||||
expect(screen.getByText("正在解析 report.docx")).toBeInTheDocument();
|
||||
fireEvent.paste(input, { clipboardData: { files } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(api.context.importFiles).toHaveBeenCalledOnce();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
act(() => finish?.(attachments));
|
||||
expect(await screen.findByText("report.docx")).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Read these files");
|
||||
await waitFor(() => expect(screen.getByLabelText("添加附件")).toBeEnabled());
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({
|
||||
prompt: "Read these files", contextIds: attachments.map(item => item.id),
|
||||
})));
|
||||
expect(api.context.addPastedImage).not.toHaveBeenCalled();
|
||||
expect(api.context.selectFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows file paste failures without clearing the draft and rejects oversized batches", async () => {
|
||||
render(<App />);
|
||||
const input = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
fireEvent.change(input, { target: { value: "Keep this draft" } });
|
||||
const file = new File(["archive"], "archive.zip");
|
||||
vi.mocked(api.context.getFilePath).mockReturnValueOnce("C:\\archive.zip");
|
||||
vi.mocked(api.context.importFiles).mockRejectedValueOnce(new Error("不支持的文件类型:.zip"));
|
||||
fireEvent.paste(input, { clipboardData: { files: [file] } });
|
||||
expect(await screen.findByRole("alert", { name: "不支持的文件类型:.zip" })).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Keep this draft");
|
||||
await waitFor(() => expect(screen.getByLabelText("添加附件")).toBeEnabled());
|
||||
fireEvent.paste(input, { clipboardData: { files: Array(9).fill(file) } });
|
||||
expect(api.context.importFiles).toHaveBeenCalledOnce();
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
fireEvent.paste(input, { clipboardData: { files: [file] } });
|
||||
expect(await screen.findByRole("alert", {
|
||||
name: "无法读取此剪贴板文件,请保存到本地后通过附件按钮添加",
|
||||
})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts pasted images without intercepting pasted text", async () => {
|
||||
vi.mocked(api.context.addPastedImage).mockResolvedValueOnce({
|
||||
id: "00000000-0000-4000-8000-000000000303",
|
||||
@@ -5895,6 +5954,7 @@ describe("App", () => {
|
||||
expect(
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [
|
||||
{
|
||||
getAsFile: () => null,
|
||||
@@ -5909,6 +5969,7 @@ describe("App", () => {
|
||||
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: {
|
||||
files: [pastedImage],
|
||||
items: [
|
||||
{
|
||||
getAsFile: () => pastedImage,
|
||||
@@ -6685,7 +6746,7 @@ describe("App", () => {
|
||||
).toHaveTextContent(/^Ask$/u);
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本",
|
||||
"给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本",
|
||||
);
|
||||
expect(
|
||||
within(conversationSettings).getByRole("button", {
|
||||
@@ -10146,7 +10207,7 @@ describe("App", () => {
|
||||
expect((await screen.findAllByText("生图")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本",
|
||||
"描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本",
|
||||
);
|
||||
await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled());
|
||||
|
||||
|
||||
+36
-11
@@ -7571,7 +7571,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const selectContextFiles = async (): Promise<void> => {
|
||||
const selectContextFiles = async (paths?: string[]): Promise<void> => {
|
||||
if (selectingContextFilesRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -7579,7 +7579,9 @@ function App(): React.JSX.Element {
|
||||
setSelectingContextFiles(true);
|
||||
setFileSelectionProgress(undefined);
|
||||
try {
|
||||
await addContext(() => window.goodbuddy.context.selectFiles());
|
||||
await addContext(() => paths
|
||||
? window.goodbuddy.context.importFiles(paths)
|
||||
: window.goodbuddy.context.selectFiles());
|
||||
} finally {
|
||||
selectingContextFilesRef.current = false;
|
||||
setSelectingContextFiles(false);
|
||||
@@ -9452,17 +9454,40 @@ function App(): React.JSX.Element {
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
const imageItem = Array.from(
|
||||
event.clipboardData.items,
|
||||
).find(
|
||||
(item) =>
|
||||
item.kind === "file" &&
|
||||
item.type.startsWith("image/"),
|
||||
);
|
||||
if (!imageItem) {
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
if (selectingContextFilesRef.current) {
|
||||
setContextError(t("composer.attachmentProgress.waitBeforeSending"));
|
||||
return;
|
||||
}
|
||||
if (files.length > 8) {
|
||||
setContextError(t("composer.errors.attachmentLimit"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const paths = files.map((file) => window.goodbuddy.context.getFilePath(file));
|
||||
if (paths.some(Boolean)) {
|
||||
if (paths.some((path) => !path)) {
|
||||
setContextError(t("composer.errors.pasteFilePath"));
|
||||
return;
|
||||
}
|
||||
void selectContextFiles(paths);
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => !file.type.startsWith("image/"))) {
|
||||
setContextError(t("composer.errors.pasteFilePath"));
|
||||
return;
|
||||
}
|
||||
} catch (reason) {
|
||||
setContextError(reason instanceof Error ? reason.message : t("composer.errors.addContext"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const image = files.find((file) => file.type.startsWith("image/"));
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
const image = imageItem.getAsFile();
|
||||
const mimeType =
|
||||
image?.type === "image/jpeg" ||
|
||||
image?.type === "image/png" ||
|
||||
|
||||
@@ -435,11 +435,11 @@ export const app = {
|
||||
placeholder: 'Message GoodBuddy…',
|
||||
imagePlaceholder: 'Describe the image you want to generate…',
|
||||
keyboardHint:
|
||||
'Enter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text',
|
||||
'Enter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text',
|
||||
addContent: 'Add content',
|
||||
addAttachment: 'Add attachment',
|
||||
attachmentProgress: {
|
||||
selecting: 'Selecting attachments…',
|
||||
selecting: 'Adding attachments…',
|
||||
reading: 'Reading {{name}}',
|
||||
parsing: 'Parsing {{name}}',
|
||||
waiting: 'Files will be read and parsed after selection',
|
||||
@@ -618,6 +618,7 @@ export const app = {
|
||||
},
|
||||
errors: {
|
||||
pasteImageType: 'Only JPEG, PNG, or WebP images can be pasted',
|
||||
pasteFilePath: 'Cannot read this clipboard file. Save it locally, then use Add attachment.',
|
||||
pasteImageSize: 'Pasted images cannot exceed 12 MB',
|
||||
attachmentLimit: 'A message can include up to 8 attachments',
|
||||
addContext: 'Failed to add context'
|
||||
|
||||
@@ -416,11 +416,11 @@ export const app = {
|
||||
placeholder: '给 GoodBuddy 发消息…',
|
||||
imagePlaceholder: '描述你想生成的图片…',
|
||||
keyboardHint:
|
||||
'Enter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本',
|
||||
'Enter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本',
|
||||
addContent: '添加内容',
|
||||
addAttachment: '添加附件',
|
||||
attachmentProgress: {
|
||||
selecting: '正在选择附件…',
|
||||
selecting: '正在添加附件…',
|
||||
reading: '正在读取 {{name}}',
|
||||
parsing: '正在解析 {{name}}',
|
||||
waiting: '选择文件后将自动读取并解析',
|
||||
@@ -578,6 +578,7 @@ export const app = {
|
||||
},
|
||||
errors: {
|
||||
pasteImageType: '仅支持粘贴 JPEG、PNG 或 WebP 图片',
|
||||
pasteFilePath: '无法读取此剪贴板文件,请保存到本地后通过附件按钮添加',
|
||||
pasteImageSize: '粘贴图片不能超过 12MB',
|
||||
attachmentLimit: '单次消息最多添加 8 个附件',
|
||||
addContext: '添加上下文失败'
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
contextImportFilesSchema,
|
||||
maximumPastedImageBytes,
|
||||
pastedImageInputSchema
|
||||
} from './contracts'
|
||||
|
||||
describe('context contracts', () => {
|
||||
it('validates file batches without accepting empty paths or more than eight files', () => {
|
||||
expect(contextImportFilesSchema.parse({ paths: ['C:\\notes.txt', '/tmp/report.pdf'] }).paths).toHaveLength(2)
|
||||
for (const paths of [[], [''], Array(9).fill('/tmp/notes.txt'), [42]]) {
|
||||
expect(contextImportFilesSchema.safeParse({ paths }).success).toBe(false)
|
||||
}
|
||||
expect(contextImportFilesSchema.safeParse({ paths: ['/tmp/a.txt'], extra: true }).success).toBe(false)
|
||||
})
|
||||
it('accepts bounded pasted image bytes in supported formats', () => {
|
||||
expect(
|
||||
pastedImageInputSchema.safeParse({
|
||||
|
||||
@@ -1139,6 +1139,10 @@ export const maximumPastedImageBytes = 12 * 1024 * 1024
|
||||
|
||||
export const clipboardTextSchema = z.string()
|
||||
|
||||
export const contextImportFilesSchema = z.object({
|
||||
paths: z.array(z.string().min(1).max(32768)).min(1).max(8)
|
||||
}).strict()
|
||||
|
||||
export const pastedImageInputSchema = z
|
||||
.object({
|
||||
data: z
|
||||
@@ -2226,6 +2230,8 @@ export type DesktopApi = {
|
||||
}
|
||||
context: {
|
||||
selectFiles: () => Promise<ContextAttachment[]>
|
||||
getFilePath: (file: File) => string
|
||||
importFiles: (paths: string[]) => Promise<ContextAttachment[]>
|
||||
onFileSelectionProgress: (
|
||||
listener: (progress: ContextFileSelectionProgress) => void
|
||||
) => () => void
|
||||
|
||||
@@ -255,6 +255,7 @@ export const ipcChannels = {
|
||||
runtimeExtensionsSnapshot: 'runtime-extensions:snapshot',
|
||||
runtimeExtensionsApply: 'runtime-extensions:apply',
|
||||
contextSelectFiles: 'context:select-files',
|
||||
contextImportFiles: 'context:import-files',
|
||||
contextFileSelectionProgress: 'context:file-selection-progress',
|
||||
contextAddPastedImage: 'context:add-pasted-image',
|
||||
contextCaptureScreen: 'context:capture-screen',
|
||||
|
||||
Reference in New Issue
Block a user