mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 05:31:02 +00:00
* feat(vscode-ide-companion): add image paste support - Add clipboard image paste functionality with drag-and-drop support - Implement image preview component with removal capability - Support multimodal content in ACP session manager for text and images - Save pasted images to temporary .gemini-clipboard directory - Add image attachment display in user messages - Update CSP to allow data: URIs for inline image display - Add comprehensive image utilities with size validation (max 10MB) - Include tests for image processing utilities * refactor: simplify VS Code paste image implementation - Remove dead code and redundant error handling - Extract common isAuthError() helper function - Simplify SessionMessageHandler methods (80% reduction) - Change temp directory from .gemini-clipboard to clipboard (aligned with CLI) - Keep multimodal image sending format (type: image + base64) Stats: - 6 files changed - 367 insertions (+) - 1176 deletions (-) * refactor: align paste image handling * chore: trim paste image diff * refactor(vscode-ide-companion): remove unused attachments logic - Remove unused ImageAttachment type imports - Remove attachments field from TextMessage interface - Remove attachments from message data sent to WebView - Clean up debug console.log statements - Simplify SessionMessageHandler handleSendMessage method This removes dead code from the previous image paste implementation that was no longer needed after switching to @path reference approach. * refactor(vscode-ide-companion/webview): extract image handling into dedicated hooks and utils - extract ImagePreview and ImageMessageRenderer components from App.tsx - create useImageAttachments hook for managing image attachments - create useImageResolution hook for image path resolution - add imageAttachmentHandler for saving images to temp files - add imageMessageUtils for message expansion and resolution - add imagePathResolver for resolving image paths in webview - integrate image resolution in useWebViewMessages - extract shouldSendMessage utility from useMessageSubmit - add getLocalResourceRoots in PanelManager for resource access Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix: harden vscode image handling and webview hosts * fix: remove this alias in acp connection * feat: add path escaping utility functions and tests * feat: add support for image attachments and improve prompt handling * refactor(webview): Optimize editing mode switching function * refactor(vscode-ide-companion): move path escaping utilities to local module - Move escapePath and unescapePath functions from qwen-code-core to local utils - Add pathEscaping.ts with shell special characters handling - Update imports in imageFormats.ts, imageAttachmentHandler.ts, and imageMessageUtils.ts - Add unit tests for path escaping round-trip and browser bundle verification - Fix browser bundling issue by avoiding node-only module dependencies in webview Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor: consolidate image handling logic across vscode-ide-companion and webui - Merge分散的 image hooks (useImageAttachments, useImageResolution, usePasteHandler) into unified useImage hook - Replace image utils (imageMessageUtils, imagePathResolver, imageUtils) with imageHandler and imageSupport - Remove clipboard image storage from core package - Consolidate webui image components into ImageComponents.tsx - Update imports and tests to reflect new structure Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * chore: drop unrelated core tool changes * test: fix webview provider mocks and drop unrelated core diffs * fix(cli): resolve original prompt through standard path in no_command case Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
219 lines
6 KiB
TypeScript
219 lines
6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
normalizeImageAttachment,
|
|
escapePath,
|
|
unescapePath,
|
|
} from '../../utils/imageSupport.js';
|
|
|
|
const mockMkdir = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
const mockWriteFile = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
const mockReaddir = vi.hoisted(() => vi.fn().mockResolvedValue([]));
|
|
const mockStat = vi.hoisted(() => vi.fn());
|
|
const mockUnlink = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
mkdir: mockMkdir,
|
|
writeFile: mockWriteFile,
|
|
readdir: mockReaddir,
|
|
stat: mockStat,
|
|
unlink: mockUnlink,
|
|
}));
|
|
|
|
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
|
return {
|
|
...actual,
|
|
Storage: { getGlobalTempDir: () => '/mock/tmp' },
|
|
};
|
|
});
|
|
|
|
vi.mock('vscode', () => ({
|
|
workspace: {
|
|
workspaceFolders: [],
|
|
},
|
|
}));
|
|
|
|
import {
|
|
processImageAttachments,
|
|
saveImageToFile,
|
|
buildPromptBlocks,
|
|
} from './imageHandler.js';
|
|
|
|
describe('imageHandler', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('decodes base64 data URL and writes correct buffer to disk', async () => {
|
|
const filePath = await saveImageToFile(
|
|
'data:image/png;base64,YWJj',
|
|
'image/png',
|
|
);
|
|
|
|
expect(filePath).toBeTruthy();
|
|
expect(mockMkdir).toHaveBeenCalledWith(
|
|
path.join('/mock/tmp', 'clipboard'),
|
|
{ recursive: true },
|
|
);
|
|
expect(mockWriteFile).toHaveBeenCalledOnce();
|
|
|
|
const [writtenPath, buffer] = mockWriteFile.mock.calls[0];
|
|
expect(buffer).toEqual(Buffer.from('abc'));
|
|
expect(path.basename(writtenPath)).toMatch(
|
|
/^clipboard-\d+-[a-f0-9-]+\.png$/,
|
|
);
|
|
});
|
|
|
|
it('decodes raw base64 (without data URL prefix)', async () => {
|
|
const filePath = await saveImageToFile('YWJj', 'image/png');
|
|
|
|
expect(filePath).toBeTruthy();
|
|
const [, buffer] = mockWriteFile.mock.calls[0];
|
|
expect(buffer).toEqual(Buffer.from('abc'));
|
|
});
|
|
|
|
it('prunes old clipboard images after saving', async () => {
|
|
mockReaddir.mockResolvedValueOnce(['clipboard-1.png', 'clipboard-2.png']);
|
|
mockStat
|
|
.mockResolvedValueOnce({ mtimeMs: 100 })
|
|
.mockResolvedValueOnce({ mtimeMs: 200 });
|
|
|
|
await saveImageToFile('data:image/png;base64,YWJj', 'image/png');
|
|
|
|
expect(mockReaddir).toHaveBeenCalled();
|
|
});
|
|
|
|
it('generates unique file names for images saved in the same millisecond', async () => {
|
|
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
|
|
|
|
await saveImageToFile('data:image/png;base64,YWJj', 'image/png');
|
|
await saveImageToFile('data:image/png;base64,ZGVm', 'image/png');
|
|
|
|
const firstName = path.basename(mockWriteFile.mock.calls[0][0]);
|
|
const secondName = path.basename(mockWriteFile.mock.calls[1][0]);
|
|
expect(firstName).not.toBe(secondName);
|
|
});
|
|
|
|
it('returns null when file write throws', async () => {
|
|
mockWriteFile.mockRejectedValueOnce(new Error('disk full'));
|
|
const result = await saveImageToFile(
|
|
'data:image/png;base64,YWJj',
|
|
'image/png',
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns saved prompt image metadata for validated attachments', async () => {
|
|
const result = await processImageAttachments('Inspect this image', [
|
|
{
|
|
id: 'img-1',
|
|
name: 'pasted.png',
|
|
type: 'image/png',
|
|
size: 3,
|
|
data: 'data:image/png;base64,YWJj',
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
|
|
expect(result.savedImageCount).toBe(1);
|
|
expect(result.promptImages).toEqual([
|
|
expect.objectContaining({
|
|
name: 'pasted.png',
|
|
mimeType: 'image/png',
|
|
path: expect.stringContaining(`${path.sep}clipboard-`),
|
|
}),
|
|
]);
|
|
expect(result.formattedText).toContain('@');
|
|
});
|
|
});
|
|
|
|
describe('buildPromptBlocks', () => {
|
|
it('builds ACP resource_link blocks from saved image attachments', () => {
|
|
expect(
|
|
buildPromptBlocks('Please inspect this screenshot.', [
|
|
{
|
|
path: '/tmp/My Images/pasted image.png',
|
|
name: 'pasted image.png',
|
|
mimeType: 'image/png',
|
|
},
|
|
]),
|
|
).toEqual([
|
|
{ type: 'text', text: 'Please inspect this screenshot.' },
|
|
{
|
|
type: 'resource_link',
|
|
name: 'pasted image.png',
|
|
mimeType: 'image/png',
|
|
uri: 'file:///tmp/My Images/pasted image.png',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('returns only resource links when the prompt has images only', () => {
|
|
expect(
|
|
buildPromptBlocks('', [
|
|
{
|
|
path: '/tmp/clipboard/pasted.webp',
|
|
name: 'pasted.webp',
|
|
mimeType: 'image/webp',
|
|
},
|
|
]),
|
|
).toEqual([
|
|
{
|
|
type: 'resource_link',
|
|
name: 'pasted.webp',
|
|
mimeType: 'image/webp',
|
|
uri: 'file:///tmp/clipboard/pasted.webp',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('normalizeImageAttachment', () => {
|
|
it('rejects attachments with unsupported image mime types', () => {
|
|
expect(
|
|
normalizeImageAttachment({
|
|
id: 'img-1',
|
|
name: 'animated.gif',
|
|
type: 'image/gif',
|
|
size: 43,
|
|
data: 'data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=',
|
|
timestamp: Date.now(),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('rejects attachments whose decoded payload exceeds the enforced byte limit', () => {
|
|
expect(
|
|
normalizeImageAttachment(
|
|
{
|
|
id: 'img-2',
|
|
name: 'oversized.png',
|
|
type: 'image/png',
|
|
size: 1,
|
|
data: 'data:image/png;base64,QUJDREU=',
|
|
timestamp: Date.now(),
|
|
},
|
|
{ maxBytes: 4 },
|
|
),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('pathEscaping', () => {
|
|
it('round-trips shell-escaped file paths', () => {
|
|
const originalPath = '/tmp/My Images/(draft) final.png';
|
|
expect(unescapePath(escapePath(originalPath))).toBe(originalPath);
|
|
});
|
|
});
|