Compare commits

...

9 Commits

Author SHA1 Message Date
yiliang114
8694192a2e feat(core): add clipboard image storage utilities and update VSCode IDE companion for image attachments
- Rename .gemini-clipboard to .qwen-code-clipboard across codebase
- Add new clipboardImageStorage module with sync/async methods for handling clipboard images
- Export clipboard utilities from core package
- Update VSCode IDE companion to handle image attachments in chat messages
- Refactor SessionMessageHandler to use shared clipboard utilities
- Add proper cleanup for old clipboard images
- Update paste handler to properly filter image files

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-21 14:17:17 +08:00
yiliang114
5ddaf90bca Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-paste-image 2026-01-21 13:23:52 +08:00
Mingholy
6eb16c0bcf Merge pull request #1548 from QwenLM/mingholy/fix/qwen-oauth-model-info
Fix: Update Qwen OAuth model information
2026-01-20 16:16:30 +08:00
tanzhenxin
7fa1dcb0e6 Merge pull request #1550 from QwenLM/refactor/acp-error-codes
fix(acp): propagate ENOENT errors correctly and centralize error codes
2026-01-20 16:03:16 +08:00
tanzhenxin
3c68a9a5f6 test(acp): update filesystem tests for error code-based ENOENT handling 2026-01-20 15:40:09 +08:00
tanzhenxin
bdfeec24fb refactor(acp): centralize error codes and add RESOURCE_NOT_FOUND handling for file operations 2026-01-20 15:19:18 +08:00
mingholy.lmh
03f12bfa3f fix: update qwen-oauth models info 2026-01-20 15:11:11 +08:00
yiliang114
cefa53481c Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-paste-image 2026-01-13 00:20:41 +08:00
yiliang114
f930adbd66 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
2026-01-04 01:03:47 +08:00
31 changed files with 1544 additions and 136 deletions

2
.gitignore vendored
View File

@@ -12,7 +12,7 @@
!.gemini/config.yaml
!.gemini/commands/
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
# Note: .qwen-code-clipboard/ is NOT in gitignore so Qwen Code can access pasted images
# Dependency directory
node_modules

View File

@@ -8,6 +8,7 @@
import { z } from 'zod';
import * as schema from './schema.js';
import { ACP_ERROR_CODES } from './errorCodes.js';
export * from './schema.js';
import type { WritableStream, ReadableStream } from 'node:stream/web';
@@ -349,27 +350,51 @@ export class RequestError extends Error {
}
static parseError(details?: string): RequestError {
return new RequestError(-32700, 'Parse error', details);
return new RequestError(
ACP_ERROR_CODES.PARSE_ERROR,
'Parse error',
details,
);
}
static invalidRequest(details?: string): RequestError {
return new RequestError(-32600, 'Invalid request', details);
return new RequestError(
ACP_ERROR_CODES.INVALID_REQUEST,
'Invalid request',
details,
);
}
static methodNotFound(details?: string): RequestError {
return new RequestError(-32601, 'Method not found', details);
return new RequestError(
ACP_ERROR_CODES.METHOD_NOT_FOUND,
'Method not found',
details,
);
}
static invalidParams(details?: string): RequestError {
return new RequestError(-32602, 'Invalid params', details);
return new RequestError(
ACP_ERROR_CODES.INVALID_PARAMS,
'Invalid params',
details,
);
}
static internalError(details?: string): RequestError {
return new RequestError(-32603, 'Internal error', details);
return new RequestError(
ACP_ERROR_CODES.INTERNAL_ERROR,
'Internal error',
details,
);
}
static authRequired(details?: string): RequestError {
return new RequestError(-32000, 'Authentication required', details);
return new RequestError(
ACP_ERROR_CODES.AUTH_REQUIRED,
'Authentication required',
details,
);
}
toResult<T>(): Result<T> {

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const ACP_ERROR_CODES = {
// Parse error: invalid JSON received by server.
PARSE_ERROR: -32700,
// Invalid request: JSON is not a valid Request object.
INVALID_REQUEST: -32600,
// Method not found: method does not exist or is unavailable.
METHOD_NOT_FOUND: -32601,
// Invalid params: invalid method parameter(s).
INVALID_PARAMS: -32602,
// Internal error: implementation-defined server error.
INTERNAL_ERROR: -32603,
// Authentication required: must authenticate before operation.
AUTH_REQUIRED: -32000,
// Resource not found: e.g. missing file.
RESOURCE_NOT_FOUND: -32002,
} as const;
export type AcpErrorCode =
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];

View File

@@ -7,6 +7,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import { AcpFileSystemService } from './filesystem.js';
import { ACP_ERROR_CODES } from '../errorCodes.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
@@ -16,11 +17,13 @@ const createFallback = (): FileSystemService => ({
describe('AcpFileSystemService', () => {
describe('readTextFile ENOENT handling', () => {
it('parses path from ACP ENOENT message (quoted)', async () => {
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
const resourceNotFoundError = {
code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND,
message: 'File not found',
};
const client = {
readTextFile: vi
.fn()
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
@@ -30,15 +33,20 @@ describe('AcpFileSystemService', () => {
createFallback(),
);
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
code: 'ENOENT',
path: '/remote/file.txt',
errno: -2,
path: '/some/file.txt',
});
});
it('falls back to requested path when none provided', async () => {
it('re-throws other errors unchanged', async () => {
const otherError = {
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal error',
};
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
readTextFile: vi.fn().mockRejectedValue(otherError),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
@@ -48,12 +56,34 @@ describe('AcpFileSystemService', () => {
createFallback(),
);
await expect(
svc.readTextFile('/fallback/path.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
path: '/fallback/path.txt',
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal error',
});
});
it('uses fallback when readTextFile capability is disabled', async () => {
const client = {
readTextFile: vi.fn(),
} as unknown as import('../acp.js').Client;
const fallback = createFallback();
(fallback.readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(
'fallback content',
);
const svc = new AcpFileSystemService(
client,
'session-3',
{ readTextFile: false, writeTextFile: true },
fallback,
);
const result = await svc.readTextFile('/some/file.txt');
expect(result).toBe('fallback content');
expect(fallback.readTextFile).toHaveBeenCalledWith('/some/file.txt');
expect(client.readTextFile).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,6 +6,7 @@
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import type * as acp from '../acp.js';
import { ACP_ERROR_CODES } from '../errorCodes.js';
/**
* ACP client-based implementation of FileSystemService
@@ -23,25 +24,31 @@ export class AcpFileSystemService implements FileSystemService {
return this.fallback.readTextFile(filePath);
}
const response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: null,
});
let response: { content: string };
try {
response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: null,
});
} catch (error) {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as { code?: unknown }).code
: undefined;
if (response.content.startsWith('ERROR: ENOENT:')) {
// Treat ACP error strings as structured ENOENT errors without
// assuming a specific platform format.
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
const err = new Error(response.content) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
const rawPath = match?.groups?.['path']?.trim();
err['path'] = rawPath
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
: filePath;
throw err;
if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) {
const err = new Error(
`File not found: ${filePath}`,
) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.path = filePath;
throw err;
}
throw error;
}
return response.content;

View File

@@ -376,7 +376,7 @@ describe('InputPrompt', () => {
it('should handle Ctrl+V when clipboard has an image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-123.png',
'/test/.qwen-code-clipboard/clipboard-123.png',
);
const { stdin, unmount } = renderWithProviders(
@@ -436,7 +436,7 @@ describe('InputPrompt', () => {
it('should insert image path at cursor position with proper spacing', async () => {
const imagePath = path.join(
'test',
'.gemini-clipboard',
'.qwen-code-clipboard',
'clipboard-456.png',
);
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);

View File

@@ -1,12 +1,17 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { execCommand } from '@qwen-code/qwen-code-core';
import {
execCommand,
ensureClipboardImageDir,
generateClipboardImageFilename,
cleanupOldClipboardImages as cleanupOldImages,
} from '@qwen-code/qwen-code-core';
/**
* Checks if the system clipboard contains an image (macOS only for now)
@@ -30,6 +35,7 @@ export async function clipboardHasImage(): Promise<boolean> {
/**
* Saves the image from clipboard to a temporary file (macOS only for now)
* Uses osascript to read from system clipboard and save to file.
* @param targetDir The target directory to create temp files within
* @returns The path to the saved image file, or null if no image or error
*/
@@ -44,11 +50,7 @@ export async function saveClipboardImage(
// Create a temporary directory for clipboard images within the target directory
// This avoids security restrictions on paths outside the target directory
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.gemini-clipboard');
await fs.mkdir(tempDir, { recursive: true });
// Generate a unique filename with timestamp
const timestamp = new Date().getTime();
const tempDir = await ensureClipboardImageDir(baseDir);
// Try different image formats in order of preference
const formats = [
@@ -61,7 +63,7 @@ export async function saveClipboardImage(
for (const format of formats) {
const tempFilePath = path.join(
tempDir,
`clipboard-${timestamp}.${format.extension}`,
generateClipboardImageFilename(format.extension),
);
// Try to save clipboard as this format
@@ -118,28 +120,6 @@ export async function saveClipboardImage(
export async function cleanupOldClipboardImages(
targetDir?: string,
): Promise<void> {
try {
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.gemini-clipboard');
const files = await fs.readdir(tempDir);
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const file of files) {
if (
file.startsWith('clipboard-') &&
(file.endsWith('.png') ||
file.endsWith('.jpg') ||
file.endsWith('.tiff') ||
file.endsWith('.gif'))
) {
const filePath = path.join(tempDir, file);
const stats = await fs.stat(filePath);
if (stats.mtimeMs < oneHourAgo) {
await fs.unlink(filePath);
}
}
}
} catch {
// Ignore errors in cleanup
}
const baseDir = targetDir || process.cwd();
await cleanupOldImages(baseDir);
}

View File

@@ -76,6 +76,7 @@ export * from './utils/subagentGenerator.js';
export * from './utils/projectSummary.js';
export * from './utils/promptIdContext.js';
export * from './utils/thoughtUtils.js';
export * from './utils/clipboardImageStorage.js';
// Config resolution utilities
export * from './utils/configResolver.js';

View File

@@ -102,16 +102,14 @@ export const QWEN_OAUTH_ALLOWED_MODELS = [
export const QWEN_OAUTH_MODELS: ModelConfig[] = [
{
id: 'coder-model',
name: 'Qwen Coder',
description:
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
name: 'coder-model',
description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio',
capabilities: { vision: false },
},
{
id: 'vision-model',
name: 'Qwen Vision',
description:
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
name: 'vision-model',
description: 'The latest Qwen Vision model from Alibaba Cloud ModelStudio',
capabilities: { vision: true },
},
];

View File

@@ -0,0 +1,246 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
CLIPBOARD_IMAGE_DIR,
CLEANUP_THRESHOLD_MS,
SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS,
getClipboardImageDir,
ensureClipboardImageDir,
ensureClipboardImageDirSync,
generateClipboardImageFilename,
saveBase64Image,
saveBase64ImageSync,
cleanupOldClipboardImages,
isSupportedClipboardImageExtension,
} from './clipboardImageStorage.js';
describe('clipboardImageStorage', () => {
let tempDir: string;
beforeEach(async () => {
// Create a temporary directory for tests
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'clipboard-test-'));
});
afterEach(async () => {
// Clean up temporary directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('constants', () => {
it('should have correct clipboard image directory name', () => {
expect(CLIPBOARD_IMAGE_DIR).toBe('.qwen-code-clipboard');
});
it('should have correct cleanup threshold (1 hour)', () => {
expect(CLEANUP_THRESHOLD_MS).toBe(60 * 60 * 1000);
});
it('should support common image extensions', () => {
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.png');
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.jpg');
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.jpeg');
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.gif');
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.webp');
});
});
describe('getClipboardImageDir', () => {
it('should return correct path', () => {
const result = getClipboardImageDir('/workspace');
expect(result).toBe(path.join('/workspace', '.qwen-code-clipboard'));
});
});
describe('ensureClipboardImageDir', () => {
it('should create directory if not exists', async () => {
const dir = await ensureClipboardImageDir(tempDir);
expect(dir).toBe(path.join(tempDir, CLIPBOARD_IMAGE_DIR));
const stats = await fs.stat(dir);
expect(stats.isDirectory()).toBe(true);
});
it('should not fail if directory already exists', async () => {
await ensureClipboardImageDir(tempDir);
const dir = await ensureClipboardImageDir(tempDir);
expect(dir).toBe(path.join(tempDir, CLIPBOARD_IMAGE_DIR));
});
});
describe('ensureClipboardImageDirSync', () => {
it('should create directory if not exists', () => {
const dir = ensureClipboardImageDirSync(tempDir);
expect(dir).toBe(path.join(tempDir, CLIPBOARD_IMAGE_DIR));
expect(fsSync.existsSync(dir)).toBe(true);
});
});
describe('generateClipboardImageFilename', () => {
it('should generate filename with timestamp and extension', () => {
const filename = generateClipboardImageFilename('.png');
expect(filename).toMatch(/^clipboard-\d+\.png$/);
});
it('should handle extension without dot', () => {
const filename = generateClipboardImageFilename('jpg');
expect(filename).toMatch(/^clipboard-\d+\.jpg$/);
});
});
describe('saveBase64Image', () => {
it('should save base64 image to file', async () => {
// Simple 1x1 red PNG in base64
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const relativePath = await saveBase64Image(
base64Data,
'test.png',
tempDir,
);
expect(relativePath).not.toBeNull();
expect(relativePath).toMatch(
/^\.qwen-code-clipboard\/clipboard-\d+\.png$/,
);
const fullPath = path.join(tempDir, relativePath!);
const stats = await fs.stat(fullPath);
expect(stats.size).toBeGreaterThan(0);
});
it('should handle data URL format', async () => {
const base64Data =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const relativePath = await saveBase64Image(
base64Data,
'test.png',
tempDir,
);
expect(relativePath).not.toBeNull();
const fullPath = path.join(tempDir, relativePath!);
const stats = await fs.stat(fullPath);
expect(stats.size).toBeGreaterThan(0);
});
it('should use default extension if not provided', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const relativePath = await saveBase64Image(base64Data, 'noext', tempDir);
expect(relativePath).toMatch(/\.png$/);
});
});
describe('saveBase64ImageSync', () => {
it('should save base64 image to file synchronously', () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const relativePath = saveBase64ImageSync(base64Data, 'test.png', tempDir);
expect(relativePath).not.toBeNull();
expect(relativePath).toMatch(
/^\.qwen-code-clipboard\/clipboard-\d+\.png$/,
);
const fullPath = path.join(tempDir, relativePath!);
expect(fsSync.existsSync(fullPath)).toBe(true);
});
});
describe('cleanupOldClipboardImages', () => {
it('should remove files older than threshold', async () => {
// Create clipboard directory
const clipboardDir = await ensureClipboardImageDir(tempDir);
// Create an old file
const oldFilePath = path.join(clipboardDir, 'clipboard-1234567890.png');
await fs.writeFile(oldFilePath, 'test');
// Set mtime to 2 hours ago
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
await fs.utimes(oldFilePath, twoHoursAgo, twoHoursAgo);
// Create a new file
const newFilePath = path.join(clipboardDir, 'clipboard-9999999999.png');
await fs.writeFile(newFilePath, 'test');
// Run cleanup
await cleanupOldClipboardImages(tempDir);
// Old file should be deleted
await expect(fs.access(oldFilePath)).rejects.toThrow();
// New file should still exist
await expect(fs.access(newFilePath)).resolves.toBeUndefined();
});
it('should not fail if directory does not exist', async () => {
// Should not throw
await expect(cleanupOldClipboardImages(tempDir)).resolves.toBeUndefined();
});
it('should only clean clipboard-* files', async () => {
const clipboardDir = await ensureClipboardImageDir(tempDir);
// Create a non-clipboard file
const otherFilePath = path.join(clipboardDir, 'other-file.png');
await fs.writeFile(otherFilePath, 'test');
// Set mtime to 2 hours ago
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
await fs.utimes(otherFilePath, twoHoursAgo, twoHoursAgo);
// Run cleanup
await cleanupOldClipboardImages(tempDir);
// Other file should still exist
await expect(fs.access(otherFilePath)).resolves.toBeUndefined();
});
});
describe('isSupportedClipboardImageExtension', () => {
it('should return true for supported extensions', () => {
expect(isSupportedClipboardImageExtension('.png')).toBe(true);
expect(isSupportedClipboardImageExtension('.jpg')).toBe(true);
expect(isSupportedClipboardImageExtension('.jpeg')).toBe(true);
expect(isSupportedClipboardImageExtension('.gif')).toBe(true);
expect(isSupportedClipboardImageExtension('.webp')).toBe(true);
});
it('should return true for extensions without dot', () => {
expect(isSupportedClipboardImageExtension('png')).toBe(true);
expect(isSupportedClipboardImageExtension('jpg')).toBe(true);
});
it('should return false for unsupported extensions', () => {
expect(isSupportedClipboardImageExtension('.txt')).toBe(false);
expect(isSupportedClipboardImageExtension('.pdf')).toBe(false);
expect(isSupportedClipboardImageExtension('.doc')).toBe(false);
});
it('should be case insensitive', () => {
expect(isSupportedClipboardImageExtension('.PNG')).toBe(true);
expect(isSupportedClipboardImageExtension('.JPG')).toBe(true);
});
});
});

View File

@@ -0,0 +1,215 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
/**
* Directory name for storing clipboard images
* This directory is NOT in .gitignore so the AI can access pasted images
*/
export const CLIPBOARD_IMAGE_DIR = '.qwen-code-clipboard';
/**
* Default cleanup threshold: 1 hour
*/
export const CLEANUP_THRESHOLD_MS = 60 * 60 * 1000;
/**
* Supported image extensions for clipboard images
*/
export const SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.tiff',
'.webp',
'.bmp',
];
/**
* Get the full path to the clipboard image directory
* @param baseDir The base directory (usually workspace root)
* @returns Full path to the clipboard image directory
*/
export function getClipboardImageDir(baseDir: string): string {
return path.join(baseDir, CLIPBOARD_IMAGE_DIR);
}
/**
* Ensure the clipboard image directory exists
* @param baseDir The base directory (usually workspace root)
* @returns Full path to the clipboard image directory
*/
export async function ensureClipboardImageDir(
baseDir: string,
): Promise<string> {
const dir = getClipboardImageDir(baseDir);
await fs.mkdir(dir, { recursive: true });
return dir;
}
/**
* Ensure the clipboard image directory exists (sync version)
* @param baseDir The base directory (usually workspace root)
* @returns Full path to the clipboard image directory
*/
export function ensureClipboardImageDirSync(baseDir: string): string {
const dir = getClipboardImageDir(baseDir);
if (!fsSync.existsSync(dir)) {
fsSync.mkdirSync(dir, { recursive: true });
}
return dir;
}
/**
* Generate a unique filename for a clipboard image
* @param extension File extension (with or without dot)
* @returns Generated filename like "clipboard-1234567890.png"
*/
export function generateClipboardImageFilename(extension: string): string {
const timestamp = Date.now();
const ext = extension.startsWith('.') ? extension : `.${extension}`;
return `clipboard-${timestamp}${ext}`;
}
/**
* Save a base64 encoded image to the clipboard image directory
* @param base64Data Base64 encoded image data (with or without data URL prefix)
* @param fileName Original filename or generated filename
* @param baseDir The base directory (usually workspace root)
* @returns Relative path from baseDir to the saved file, or null if failed
*/
export async function saveBase64Image(
base64Data: string,
fileName: string,
baseDir: string,
): Promise<string | null> {
try {
const dir = await ensureClipboardImageDir(baseDir);
// Generate unique filename
const ext = path.extname(fileName) || '.png';
const tempFileName = generateClipboardImageFilename(ext);
const tempFilePath = path.join(dir, tempFileName);
// Extract base64 data if it's a data URL
let pureBase64 = base64Data;
const dataUrlMatch = base64Data.match(/^data:[^;]+;base64,(.+)$/);
if (dataUrlMatch) {
pureBase64 = dataUrlMatch[1];
}
// Write file
const buffer = Buffer.from(pureBase64, 'base64');
await fs.writeFile(tempFilePath, buffer);
// Return relative path from baseDir
return path.relative(baseDir, tempFilePath);
} catch (error) {
console.error('[clipboardImageStorage] Failed to save image:', error);
return null;
}
}
/**
* Save a base64 encoded image to the clipboard image directory (sync version)
* @param base64Data Base64 encoded image data (with or without data URL prefix)
* @param fileName Original filename or generated filename
* @param baseDir The base directory (usually workspace root)
* @returns Relative path from baseDir to the saved file, or null if failed
*/
export function saveBase64ImageSync(
base64Data: string,
fileName: string,
baseDir: string,
): string | null {
try {
const dir = ensureClipboardImageDirSync(baseDir);
// Generate unique filename
const ext = path.extname(fileName) || '.png';
const tempFileName = generateClipboardImageFilename(ext);
const tempFilePath = path.join(dir, tempFileName);
// Extract base64 data if it's a data URL
let pureBase64 = base64Data;
const dataUrlMatch = base64Data.match(/^data:[^;]+;base64,(.+)$/);
if (dataUrlMatch) {
pureBase64 = dataUrlMatch[1];
}
// Write file
const buffer = Buffer.from(pureBase64, 'base64');
fsSync.writeFileSync(tempFilePath, buffer);
// Return relative path from baseDir
return path.relative(baseDir, tempFilePath);
} catch (error) {
console.error('[clipboardImageStorage] Failed to save image:', error);
return null;
}
}
/**
* Clean up old clipboard image files
* Removes files older than the specified threshold
* @param baseDir The base directory (usually workspace root)
* @param thresholdMs Age threshold in milliseconds (default: 1 hour)
*/
export async function cleanupOldClipboardImages(
baseDir: string,
thresholdMs: number = CLEANUP_THRESHOLD_MS,
): Promise<void> {
try {
const dir = getClipboardImageDir(baseDir);
// Check if directory exists
try {
await fs.access(dir);
} catch {
// Directory doesn't exist, nothing to clean
return;
}
const files = await fs.readdir(dir);
const cutoffTime = Date.now() - thresholdMs;
for (const file of files) {
// Only clean up clipboard-* files with supported extensions
if (file.startsWith('clipboard-')) {
const ext = path.extname(file).toLowerCase();
if (SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS.includes(ext)) {
const filePath = path.join(dir, file);
try {
const stats = await fs.stat(filePath);
if (stats.mtimeMs < cutoffTime) {
await fs.unlink(filePath);
}
} catch {
// Ignore errors for individual files
}
}
}
}
} catch {
// Ignore errors in cleanup
}
}
/**
* Check if a file extension is a supported clipboard image format
* @param extension File extension (with or without dot)
* @returns true if supported
*/
export function isSupportedClipboardImageExtension(extension: string): boolean {
const ext = extension.startsWith('.')
? extension.toLowerCase()
: `.${extension.toLowerCase()}`;
return SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS.includes(ext);
}

View File

@@ -23,3 +23,23 @@ export const CLIENT_METHODS = {
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;
export const ACP_ERROR_CODES = {
// Parse error: invalid JSON received by server.
PARSE_ERROR: -32700,
// Invalid request: JSON is not a valid Request object.
INVALID_REQUEST: -32600,
// Method not found: method does not exist or is unavailable.
METHOD_NOT_FOUND: -32601,
// Invalid params: invalid method parameter(s).
INVALID_PARAMS: -32602,
// Internal error: implementation-defined server error.
INTERNAL_ERROR: -32603,
// Authentication required: must authenticate before operation.
AUTH_REQUIRED: -32000,
// Resource not found: e.g. missing file.
RESOURCE_NOT_FOUND: -32002,
} as const;
export type AcpErrorCode =
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];

View File

@@ -28,6 +28,7 @@ import * as os from 'node:os';
import type { z } from 'zod';
import type { DiffManager } from './diff-manager.js';
import { OpenFilesManager } from './open-files-manager.js';
import { ACP_ERROR_CODES } from './constants/acpSchema.js';
class CORSError extends Error {
constructor(message: string) {
@@ -264,7 +265,7 @@ export class IDEServer {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
code: ACP_ERROR_CODES.AUTH_REQUIRED,
message:
'Bad Request: No valid session ID provided for non-initialize request.',
},
@@ -283,7 +284,7 @@ export class IDEServer {
res.status(500).json({
jsonrpc: '2.0' as const,
error: {
code: -32603,
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal server error',
},
id: null,

View File

@@ -5,6 +5,7 @@
*/
import { JSONRPC_VERSION } from '../types/acpTypes.js';
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
import type {
AcpMessage,
AcpPermissionRequest,
@@ -20,7 +21,7 @@ import type {
AcpConnectionCallbacks,
} from '../types/connectionTypes.js';
import { AcpMessageHandler } from './acpMessageHandler.js';
import { AcpSessionManager } from './acpSessionManager.js';
import { AcpSessionManager, type PromptContent } from './acpSessionManager.js';
import * as fs from 'node:fs';
/**
@@ -232,12 +233,34 @@ export class AcpConnection {
})
.catch((error) => {
if ('id' in message && typeof message.id === 'number') {
const errorMessage =
error instanceof Error
? error.message
: typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: String(error);
let errorCode: number = ACP_ERROR_CODES.INTERNAL_ERROR;
const errorCodeValue =
typeof error === 'object' && error !== null && 'code' in error
? (error as { code?: unknown }).code
: undefined;
if (typeof errorCodeValue === 'number') {
errorCode = errorCodeValue;
} else if (errorCodeValue === 'ENOENT') {
errorCode = ACP_ERROR_CODES.RESOURCE_NOT_FOUND;
}
this.messageHandler.sendResponseMessage(this.child, {
jsonrpc: JSONRPC_VERSION,
id: message.id,
error: {
code: -32603,
message: error instanceof Error ? error.message : String(error),
code: errorCode,
message: errorMessage,
},
});
}
@@ -283,12 +306,12 @@ export class AcpConnection {
}
/**
* Send prompt message
* Send prompt message with support for multimodal content
*
* @param prompt - Prompt content
* @param prompt - Either a plain text string or array of content items
* @returns Response
*/
async sendPrompt(prompt: string): Promise<AcpResponse> {
async sendPrompt(prompt: string | PromptContent[]): Promise<AcpResponse> {
return this.sessionManager.sendPrompt(
prompt,
this.child,

View File

@@ -66,6 +66,11 @@ export class AcpFileHandler {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg);
const nodeError = error as NodeJS.ErrnoException;
if (nodeError?.code === 'ENOENT') {
throw error;
}
throw new Error(`Failed to read file '${params.path}': ${errorMsg}`);
}
}

View File

@@ -21,6 +21,13 @@ import type { PendingRequest } from '../types/connectionTypes.js';
import type { ChildProcess } from 'child_process';
import { isWindows } from '../utils/platform.js';
/**
* Prompt content types for multimodal messages
*/
export type PromptContent =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string };
/**
* ACP Session Manager Class
* Provides session initialization, authentication, creation, loading, and switching functionality
@@ -213,9 +220,9 @@ export class AcpSessionManager {
}
/**
* Send prompt message
* Send prompt message with support for multimodal content (text and images)
*
* @param prompt - Prompt content
* @param prompt - Either a plain text string or array of content items
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
@@ -223,7 +230,7 @@ export class AcpSessionManager {
* @throws Error when there is no active session
*/
async sendPrompt(
prompt: string,
prompt: string | PromptContent[],
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
@@ -232,11 +239,35 @@ export class AcpSessionManager {
throw new Error('No active ACP session');
}
// Convert string to array format for backward compatibility
const promptContent: PromptContent[] =
typeof prompt === 'string' ? [{ type: 'text', text: prompt }] : prompt;
// Debug log to see what we're sending
console.log(
'[ACP] Sending prompt with content:',
JSON.stringify(promptContent, null, 2),
);
console.log(
'[ACP] Content types:',
promptContent.map((c) => c.type),
);
if (promptContent.some((c) => c.type === 'image')) {
console.log('[ACP] Message includes images');
promptContent.forEach((content, index) => {
if (content.type === 'image') {
console.log(
`[ACP] Image ${index}: mimeType=${content.mimeType}, data length=${content.data.length}`,
);
}
});
}
return await this.sendRequest(
AGENT_METHODS.session_prompt,
{
sessionId: this.sessionId,
prompt: [{ type: 'text', text: prompt }],
prompt: promptContent,
},
child,
pendingRequests,

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { AcpConnection } from './acpConnection.js';
import type { PromptContent } from './acpSessionManager.js';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
@@ -215,7 +216,7 @@ export class QwenAgentManager {
*
* @param message - Message content
*/
async sendMessage(message: string): Promise<void> {
async sendMessage(message: string | PromptContent[]): Promise<void> {
await this.connection.sendPrompt(message);
}

View File

@@ -10,6 +10,14 @@ export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
timestamp: number;
attachments?: Array<{
id: string;
name: string;
type: string;
size: number;
data: string;
timestamp: number;
}>;
}
export interface PlanEntry {

View File

@@ -4,9 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
const AUTH_ERROR_PATTERNS = [
'Authentication required', // Standard authentication request message
'(code: -32000)', // RPC error code -32000 indicates authentication failure
`(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`, // RPC error code indicates auth failure
'Unauthorized', // HTTP unauthorized error
'Invalid token', // Invalid token
'Session expired', // Session expired

View File

@@ -51,6 +51,9 @@ import {
DEFAULT_TOKEN_LIMIT,
tokenLimit,
} from '@qwen-code/qwen-code-core/src/core/tokenLimits.js';
import type { ImageAttachment } from './utils/imageUtils.js';
import { formatFileSize, MAX_TOTAL_IMAGE_SIZE } from './utils/imageUtils.js';
import { usePasteHandler } from './hooks/usePasteHandler.js';
export const App: React.FC = () => {
const vscode = useVSCode();
@@ -68,6 +71,7 @@ export const App: React.FC = () => {
// UI state
const [inputText, setInputText] = useState('');
const [attachedImages, setAttachedImages] = useState<ImageAttachment[]>([]);
const [permissionRequest, setPermissionRequest] = useState<{
options: PermissionOption[];
toolCall: PermissionToolCall;
@@ -243,10 +247,54 @@ export const App: React.FC = () => {
completion.query,
]);
// Image handling
const handleAddImages = useCallback((newImages: ImageAttachment[]) => {
setAttachedImages((prev) => {
const currentTotal = prev.reduce((sum, img) => sum + img.size, 0);
let runningTotal = currentTotal;
const accepted: ImageAttachment[] = [];
for (const img of newImages) {
if (runningTotal + img.size > MAX_TOTAL_IMAGE_SIZE) {
console.warn(
`Skipping image "${img.name}" total attachment size would exceed ${formatFileSize(MAX_TOTAL_IMAGE_SIZE)}.`,
);
continue;
}
accepted.push(img);
runningTotal += img.size;
}
if (accepted.length === 0) {
return prev;
}
return [...prev, ...accepted];
});
}, []);
const handleRemoveImage = useCallback((imageId: string) => {
setAttachedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
const clearImages = useCallback(() => {
setAttachedImages([]);
}, []);
// Initialize paste handler
const { handlePaste } = usePasteHandler({
onImagesAdded: handleAddImages,
onError: (error) => {
console.error('Paste error:', error);
// You can show a toast/notification here if needed
},
});
// Message submission
const { handleSubmit: submitMessage } = useMessageSubmit({
inputText,
setInputText,
attachedImages,
clearImages,
messageHandling,
fileContext,
skipAutoActiveContext,
@@ -666,6 +714,7 @@ export const App: React.FC = () => {
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
fileContext={msg.fileContext}
attachments={msg.attachments}
/>
);
}
@@ -814,6 +863,7 @@ export const App: React.FC = () => {
activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext}
contextUsage={contextUsage}
attachedImages={attachedImages}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
@@ -826,6 +876,8 @@ export const App: React.FC = () => {
onToggleSkipAutoActiveContext={() =>
setSkipAutoActiveContext((v) => !v)
}
onPaste={handlePaste}
onRemoveImage={handleRemoveImage}
onShowCommandMenu={async () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();

View File

@@ -38,7 +38,7 @@ export class WebViewContent {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${panel.webview.cspSource}; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${panel.webview.cspSource} data:; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
<title>Qwen Code</title>
</head>
<body data-extension-uri="${safeExtensionUri}">

View File

@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import type { ImageAttachment } from '../utils/imageUtils.js';
interface ImagePreviewProps {
images: ImageAttachment[];
onRemove: (id: string) => void;
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({
images,
onRemove,
}) => {
if (!images || images.length === 0) {
return null;
}
return (
<div className="image-preview-container flex gap-2 px-2 pb-2">
{images.map((image) => (
<div key={image.id} className="image-preview-item relative group">
<div className="relative">
<img
src={image.data}
alt={image.name}
className="w-14 h-14 object-cover rounded-md border border-gray-500 dark:border-gray-600"
title={image.name}
/>
<button
type="button"
onClick={() => onRemove(image.id)}
className="absolute -top-2 -right-2 w-5 h-5 bg-gray-700 dark:bg-gray-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-800 dark:hover:bg-gray-500"
aria-label={`Remove ${image.name}`}
>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
))}
</div>
);
};

View File

@@ -22,6 +22,8 @@ import type { CompletionItem } from '../../../types/completionItemTypes.js';
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
import { ContextIndicator } from './ContextIndicator.js';
import { ImagePreview } from '../ImagePreview.js';
import type { ImageAttachment } from '../../utils/imageUtils.js';
interface InputFormProps {
inputText: string;
@@ -42,6 +44,7 @@ interface InputFormProps {
usedTokens: number;
tokenLimit: number;
} | null;
attachedImages?: ImageAttachment[];
onInputChange: (text: string) => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
@@ -54,6 +57,8 @@ interface InputFormProps {
onToggleSkipAutoActiveContext: () => void;
onShowCommandMenu: () => void;
onAttachContext: () => void;
onPaste?: (e: React.ClipboardEvent) => void;
onRemoveImage?: (id: string) => void;
completionIsOpen: boolean;
completionItems?: CompletionItem[];
onCompletionSelect?: (item: CompletionItem) => void;
@@ -103,6 +108,7 @@ export const InputForm: React.FC<InputFormProps> = ({
activeSelection,
skipAutoActiveContext,
contextUsage,
attachedImages = [],
onInputChange,
onCompositionStart,
onCompositionEnd,
@@ -114,6 +120,8 @@ export const InputForm: React.FC<InputFormProps> = ({
onToggleSkipAutoActiveContext,
onShowCommandMenu,
onAttachContext,
onPaste,
onRemoveImage,
completionIsOpen,
completionItems,
onCompletionSelect,
@@ -160,7 +168,7 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Banner area */}
<div className="input-banner" />
<div className="relative flex z-[1]">
<div className="relative flex flex-col z-[1]">
{completionIsOpen &&
completionItems &&
completionItems.length > 0 &&
@@ -198,8 +206,14 @@ export const InputForm: React.FC<InputFormProps> = ({
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
onKeyDown={handleKeyDown}
onPaste={onPaste}
suppressContentEditableWarning
/>
{/* Image Preview area - shown at the bottom inside the input box */}
{attachedImages.length > 0 && onRemoveImage && (
<ImagePreview images={attachedImages} onRemove={onRemoveImage} />
)}
</div>
<div className="composer-actions">

View File

@@ -6,6 +6,7 @@
import type React from 'react';
import { MessageContent } from './MessageContent.js';
import type { ImageAttachment } from '../../utils/imageUtils.js';
interface FileContext {
fileName: string;
@@ -19,6 +20,7 @@ interface UserMessageProps {
timestamp: number;
onFileClick?: (path: string) => void;
fileContext?: FileContext;
attachments?: ImageAttachment[];
}
export const UserMessage: React.FC<UserMessageProps> = ({
@@ -26,6 +28,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
timestamp: _timestamp,
onFileClick,
fileContext,
attachments,
}) => {
// Generate display text for file context
const getFileContextDisplay = () => {
@@ -66,6 +69,24 @@ export const UserMessage: React.FC<UserMessageProps> = ({
/>
</div>
{/* Display attached images */}
{attachments && attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((attachment) => (
<div key={attachment.id} className="relative">
<img
src={attachment.data}
alt={attachment.name}
className="max-w-[200px] max-h-[200px] rounded-md border border-gray-300 dark:border-gray-600"
style={{
objectFit: 'contain',
}}
/>
</div>
))}
</div>
)}
{/* File context indicator */}
{fileContextDisplay && (
<div className="mt-1">

View File

@@ -8,6 +8,14 @@ import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ChatMessage } from '../../services/qwenAgentManager.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import { ACP_ERROR_CODES } from '../../constants/acpSchema.js';
import type { PromptContent } from '../../services/acpSessionManager.js';
import {
cleanupOldClipboardImages,
saveBase64ImageSync,
} from '@qwen-code/qwen-code-core/src/utils/clipboardImageStorage.js';
const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`;
/**
* Session message handler
@@ -42,29 +50,51 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
const data = message.data as Record<string, unknown> | undefined;
type SendMessagePayload = {
text?: string;
context?: Array<{
type: string;
name: string;
value: string;
startLine?: number;
endLine?: number;
}>;
fileContext?: {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
};
attachments?: Array<{
id: string;
name: string;
type: string;
size: number;
data: string;
timestamp: number;
}>;
};
type MessageData = {
text?: string;
context?: SendMessagePayload['context'];
fileContext?: SendMessagePayload['fileContext'];
attachments?: SendMessagePayload['attachments'];
sessionId?: string;
cursor?: number;
size?: number;
tag?: string;
};
const data = message.data as MessageData | undefined;
switch (message.type) {
case 'sendMessage':
await this.handleSendMessage(
(data?.text as string) || '',
data?.context as
| Array<{
type: string;
name: string;
value: string;
startLine?: number;
endLine?: number;
}>
| undefined,
data?.fileContext as
| {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
}
| undefined,
data?.text || '',
data?.context,
data?.fileContext,
data?.attachments,
);
break;
@@ -73,22 +103,19 @@ export class SessionMessageHandler extends BaseMessageHandler {
break;
case 'switchQwenSession':
await this.handleSwitchQwenSession((data?.sessionId as string) || '');
await this.handleSwitchQwenSession(data?.sessionId || '');
break;
case 'getQwenSessions':
await this.handleGetQwenSessions(
(data?.cursor as number | undefined) ?? undefined,
(data?.size as number | undefined) ?? undefined,
);
await this.handleGetQwenSessions(data?.cursor, data?.size);
break;
case 'saveSession':
await this.handleSaveSession((data?.tag as string) || '');
await this.handleSaveSession(data?.tag || '');
break;
case 'resumeSession':
await this.handleResumeSession((data?.sessionId as string) || '');
await this.handleResumeSession(data?.sessionId || '');
break;
case 'openNewChatTab':
@@ -131,6 +158,34 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
/**
* Save base64 image to a temporary file
* Uses the shared clipboard image storage utility from core package.
* @param base64Data The base64 encoded image data (with or without data URL prefix)
* @param fileName Original filename
* @returns The relative path to the saved file or null if failed
*/
private saveImageToFile(base64Data: string, fileName: string): string | null {
// Get workspace folder
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
console.error('[SessionMessageHandler] No workspace folder found');
return null;
}
const relativePath = saveBase64ImageSync(
base64Data,
fileName,
workspaceFolder.uri.fsPath,
);
if (relativePath) {
console.log('[SessionMessageHandler] Saved image to:', relativePath);
}
return relativePath;
}
/**
* Get current stream content
*/
@@ -232,8 +287,23 @@ export class SessionMessageHandler extends BaseMessageHandler {
startLine?: number;
endLine?: number;
},
attachments?: Array<{
id: string;
name: string;
type: string;
size: number;
data: string;
timestamp: number;
}>,
): Promise<void> {
console.log('[SessionMessageHandler] handleSendMessage called with:', text);
if (attachments && attachments.length > 0) {
console.log(
'[SessionMessageHandler] Message includes',
attachments.length,
'image attachments',
);
}
// Format message with file context if present
let formattedText = text;
@@ -250,6 +320,100 @@ export class SessionMessageHandler extends BaseMessageHandler {
formattedText = `${contextParts}\n\n${text}`;
}
if (!formattedText && (!attachments || attachments.length === 0)) {
this.sendToWebView({
type: 'error',
data: { message: 'Message is empty.' },
});
return;
}
// Build prompt content
let promptContent: PromptContent[] = [];
// Add text content (with context if present)
if (formattedText) {
promptContent.push({
type: 'text',
text: formattedText,
});
}
// Add image attachments - save to files and reference them
if (attachments && attachments.length > 0) {
console.log(
'[SessionMessageHandler] Processing attachments - saving to files',
);
// Save images as files and add references to the text
const imageReferences: string[] = [];
for (const attachment of attachments) {
console.log('[SessionMessageHandler] Processing attachment:', {
id: attachment.id,
name: attachment.name,
type: attachment.type,
dataLength: attachment.data.length,
});
// Save image to file (sync operation using shared utility)
const imagePath = this.saveImageToFile(
attachment.data,
attachment.name,
);
if (imagePath) {
// Add file reference to the message (like CLI does with @path)
imageReferences.push(`@${imagePath}`);
console.log(
'[SessionMessageHandler] Added image reference:',
`@${imagePath}`,
);
} else {
console.warn(
'[SessionMessageHandler] Failed to save image:',
attachment.name,
);
}
}
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (workspaceFolder) {
cleanupOldClipboardImages(workspaceFolder.uri.fsPath).catch((error) => {
console.warn(
'[SessionMessageHandler] Failed to cleanup clipboard images:',
error,
);
});
}
// Add image references to the text
if (imageReferences.length > 0) {
const imageText = imageReferences.join(' ');
// Update the formatted text with image references
const updatedText = formattedText
? `${formattedText}\n\n${imageText}`
: imageText;
// Replace the prompt content with updated text
promptContent = [
{
type: 'text',
text: updatedText,
},
];
console.log(
'[SessionMessageHandler] Updated text with image references:',
updatedText,
);
}
}
console.log('[SessionMessageHandler] Final promptContent:', {
count: promptContent.length,
types: promptContent.map((c) => c.type),
});
// Ensure we have an active conversation
if (!this.currentConversationId) {
console.log(
@@ -318,17 +482,19 @@ export class SessionMessageHandler extends BaseMessageHandler {
role: 'user',
content: text,
timestamp: Date.now(),
...(attachments && attachments.length > 0 ? { attachments } : {}),
};
// Store the original message with just text
await this.conversationStore.addMessage(
this.currentConversationId,
userMessage,
);
// Send to WebView
// Send to WebView with file context and attachments
this.sendToWebView({
type: 'message',
data: { ...userMessage, fileContext },
data: { ...userMessage, fileContext, attachments },
});
// Check if agent is connected
@@ -355,7 +521,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
createErr instanceof Error ? createErr.message : String(createErr);
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)')
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN)
) {
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
@@ -376,7 +542,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
data: { timestamp: Date.now() },
});
await this.agentManager.sendMessage(formattedText);
// Send multimodal content instead of plain text
await this.agentManager.sendMessage(promptContent);
// Save assistant message
if (this.currentStreamContent && this.currentConversationId) {
@@ -421,7 +588,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('Session not found') ||
errorMsg.includes('No active ACP session') ||
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token')
) {
@@ -512,7 +679,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -569,7 +736,13 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
// Get session details (includes cwd and filePath when using ACP)
let sessionDetails: Record<string, unknown> | null = null;
type SessionDetails = {
id?: string;
sessionId?: string;
cwd?: string;
[key: string]: unknown;
};
let sessionDetails: SessionDetails | null = null;
try {
const allSessions = await this.agentManager.getSessionList();
sessionDetails =
@@ -598,7 +771,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
const loadResponse = await this.agentManager.loadSessionViaAcp(
sessionId,
(sessionDetails?.cwd as string | undefined) || undefined,
sessionDetails?.cwd,
);
console.log(
'[SessionMessageHandler] session/load succeeded (per ACP spec result is null; actual history comes via session/update):',
@@ -622,7 +795,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -682,7 +855,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors in session creation
if (
createErrorMsg.includes('Authentication required') ||
createErrorMsg.includes('(code: -32000)') ||
createErrorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
createErrorMsg.includes('Unauthorized') ||
createErrorMsg.includes('Invalid token') ||
createErrorMsg.includes('No active ACP session')
@@ -722,7 +895,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -777,7 +950,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -827,7 +1000,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -855,7 +1028,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -961,7 +1134,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -989,7 +1162,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')

View File

@@ -5,6 +5,7 @@
*/
import { useState, useRef, useCallback } from 'react';
import type { ImageAttachment } from '../../utils/imageUtils.js';
export interface TextMessage {
role: 'user' | 'assistant' | 'thinking';
@@ -16,6 +17,7 @@ export interface TextMessage {
startLine?: number;
endLine?: number;
};
attachments?: ImageAttachment[];
}
/**

View File

@@ -7,11 +7,14 @@
import { useCallback } from 'react';
import type { VSCodeAPI } from './useVSCode.js';
import { getRandomLoadingMessage } from '../../constants/loadingMessages.js';
import type { ImageAttachment } from '../utils/imageUtils.js';
interface UseMessageSubmitProps {
vscode: VSCodeAPI;
inputText: string;
setInputText: (text: string) => void;
attachedImages?: ImageAttachment[];
clearImages?: () => void;
inputFieldRef: React.RefObject<HTMLDivElement>;
isStreaming: boolean;
isWaitingForResponse: boolean;
@@ -39,6 +42,8 @@ export const useMessageSubmit = ({
vscode,
inputText,
setInputText,
attachedImages = [],
clearImages,
inputFieldRef,
isStreaming,
isWaitingForResponse,
@@ -142,6 +147,7 @@ export const useMessageSubmit = ({
text: inputText,
context: context.length > 0 ? context : undefined,
fileContext: fileContextForMessage,
attachments: attachedImages.length > 0 ? attachedImages : undefined,
},
});
@@ -153,9 +159,15 @@ export const useMessageSubmit = ({
inputFieldRef.current.setAttribute('data-empty', 'true');
}
fileContext.clearFileReferences();
// Clear attached images after sending
if (clearImages) {
clearImages();
}
},
[
inputText,
attachedImages,
clearImages,
isStreaming,
setInputText,
inputFieldRef,

View File

@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useRef } from 'react';
import {
createImageAttachment,
generatePastedImageName,
isSupportedImage,
isWithinSizeLimit,
formatFileSize,
type ImageAttachment,
} from '../utils/imageUtils.js';
interface UsePasteHandlerOptions {
onImagesAdded?: (images: ImageAttachment[]) => void;
onTextPaste?: (text: string) => void;
onError?: (error: string) => void;
}
export function usePasteHandler({
onImagesAdded,
onTextPaste,
onError,
}: UsePasteHandlerOptions) {
const processingRef = useRef(false);
const handlePaste = useCallback(
async (event: React.ClipboardEvent | ClipboardEvent) => {
// Prevent duplicate processing
if (processingRef.current) {
return;
}
const clipboardData = event.clipboardData;
if (!clipboardData) {
return;
}
const files = clipboardData.files;
const hasFiles = files && files.length > 0;
const imageFiles = hasFiles
? Array.from(files).filter((file) => file.type.startsWith('image/'))
: [];
// Check if there are image files in the clipboard
if (imageFiles.length > 0) {
processingRef.current = true;
event.preventDefault();
event.stopPropagation();
const imageAttachments: ImageAttachment[] = [];
const errors: string[] = [];
try {
for (const file of imageFiles) {
// Check if it's a supported image type
if (!isSupportedImage(file)) {
errors.push(`Unsupported image type: ${file.type}`);
continue;
}
// Check file size
if (!isWithinSizeLimit(file)) {
errors.push(
`Image "${file.name || 'pasted image'}" is too large (${formatFileSize(
file.size,
)}). Maximum size is 10MB.`,
);
continue;
}
try {
// If the file doesn't have a name (clipboard paste), generate one
const imageFile =
file.name && file.name !== 'image.png'
? file
: new File([file], generatePastedImageName(file.type), {
type: file.type,
});
const attachment = await createImageAttachment(imageFile);
if (attachment) {
imageAttachments.push(attachment);
}
} catch (error) {
console.error('Failed to process pasted image:', error);
errors.push(
`Failed to process image "${file.name || 'pasted image'}"`,
);
}
}
// Report errors if any
if (errors.length > 0 && onError) {
onError(errors.join('\n'));
}
// Add successfully processed images
if (imageAttachments.length > 0 && onImagesAdded) {
onImagesAdded(imageAttachments);
}
} finally {
processingRef.current = false;
}
return;
}
// Handle text paste
const text = clipboardData.getData('text/plain');
if (text && onTextPaste) {
// Let the default paste behavior handle text
// unless we want to process it specially
onTextPaste(text);
}
},
[onImagesAdded, onTextPaste, onError],
);
return { handlePaste };
}

View File

@@ -358,6 +358,20 @@ export const useWebViewMessages = ({
role?: 'user' | 'assistant' | 'thinking';
content?: string;
timestamp?: number;
fileContext?: {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
};
attachments?: Array<{
id: string;
name: string;
type: string;
size: number;
data: string;
timestamp: number;
}>;
};
handlers.messageHandling.addMessage(
msg as unknown as Parameters<

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeAll, describe, it, expect } from 'vitest';
import { Buffer } from 'node:buffer';
// Polyfill browser APIs for Node test environment
const g = globalThis as typeof globalThis & {
FileReader?: typeof FileReader;
atob?: typeof atob;
File?: typeof File;
};
if (!g.atob) {
g.atob = (b64: string) => Buffer.from(b64, 'base64').toString('binary');
}
if (!g.FileReader) {
class MockFileReader {
result: string | ArrayBuffer | null = null;
onload: ((ev: ProgressEvent<FileReader>) => void) | null = null;
onerror: ((ev: unknown) => void) | null = null;
readAsDataURL(blob: Blob) {
blob
.arrayBuffer()
.then((buf) => {
const base64 = Buffer.from(buf).toString('base64');
const mime =
(blob as { type?: string }).type || 'application/octet-stream';
this.result = `data:${mime};base64,${base64}`;
this.onload?.({} as ProgressEvent<FileReader>);
})
.catch((err) => {
this.onerror?.(err);
});
}
}
g.FileReader = MockFileReader as unknown as typeof FileReader;
}
if (!g.File) {
class MockFile extends Blob {
name: string;
lastModified: number;
constructor(
bits: BlobPart[],
name: string,
options?: BlobPropertyBag & { lastModified?: number },
) {
super(bits, options);
this.name = name;
this.lastModified = options?.lastModified ?? Date.now();
}
}
g.File = MockFile as unknown as typeof File;
}
let fileToBase64: typeof import('./imageUtils.js').fileToBase64;
let isSupportedImage: typeof import('./imageUtils.js').isSupportedImage;
let isWithinSizeLimit: typeof import('./imageUtils.js').isWithinSizeLimit;
let formatFileSize: typeof import('./imageUtils.js').formatFileSize;
let generateImageId: typeof import('./imageUtils.js').generateImageId;
let getExtensionFromMimeType: typeof import('./imageUtils.js').getExtensionFromMimeType;
beforeAll(async () => {
const mod = await import('./imageUtils.js');
fileToBase64 = mod.fileToBase64;
isSupportedImage = mod.isSupportedImage;
isWithinSizeLimit = mod.isWithinSizeLimit;
formatFileSize = mod.formatFileSize;
generateImageId = mod.generateImageId;
getExtensionFromMimeType = mod.getExtensionFromMimeType;
});
describe('Image Utils', () => {
describe('isSupportedImage', () => {
it('should accept supported image types', () => {
const pngFile = new File([''], 'test.png', { type: 'image/png' });
const jpegFile = new File([''], 'test.jpg', { type: 'image/jpeg' });
const gifFile = new File([''], 'test.gif', { type: 'image/gif' });
expect(isSupportedImage(pngFile)).toBe(true);
expect(isSupportedImage(jpegFile)).toBe(true);
expect(isSupportedImage(gifFile)).toBe(true);
});
it('should reject unsupported file types', () => {
const textFile = new File([''], 'test.txt', { type: 'text/plain' });
const pdfFile = new File([''], 'test.pdf', { type: 'application/pdf' });
expect(isSupportedImage(textFile)).toBe(false);
expect(isSupportedImage(pdfFile)).toBe(false);
});
});
describe('isWithinSizeLimit', () => {
it('should accept files under 10MB', () => {
const smallFile = new File(['a'.repeat(1024 * 1024)], 'small.png', {
type: 'image/png',
});
expect(isWithinSizeLimit(smallFile)).toBe(true);
});
it('should reject files over 10MB', () => {
// Create a mock file with size property
const largeFile = {
size: 11 * 1024 * 1024, // 11MB
} as File;
expect(isWithinSizeLimit(largeFile)).toBe(false);
});
});
describe('formatFileSize', () => {
it('should format bytes correctly', () => {
expect(formatFileSize(0)).toBe('0 B');
expect(formatFileSize(512)).toBe('512 B');
expect(formatFileSize(1024)).toBe('1 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(1024 * 1024)).toBe('1 MB');
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
});
});
describe('generateImageId', () => {
it('should generate unique IDs', () => {
const id1 = generateImageId();
const id2 = generateImageId();
expect(id1).toMatch(/^img_\d+_[a-z0-9]+$/);
expect(id2).toMatch(/^img_\d+_[a-z0-9]+$/);
expect(id1).not.toBe(id2);
});
});
describe('getExtensionFromMimeType', () => {
it('should return correct extensions', () => {
expect(getExtensionFromMimeType('image/png')).toBe('.png');
expect(getExtensionFromMimeType('image/jpeg')).toBe('.jpg');
expect(getExtensionFromMimeType('image/gif')).toBe('.gif');
expect(getExtensionFromMimeType('image/webp')).toBe('.webp');
expect(getExtensionFromMimeType('unknown/type')).toBe('.png'); // default
});
});
describe('fileToBase64', () => {
it('should convert file to base64', async () => {
const content = 'test content';
const file = new File([content], 'test.txt', { type: 'text/plain' });
const base64 = await fileToBase64(file);
expect(base64).toMatch(/^data:text\/plain;base64,/);
// Decode and verify content
const base64Content = base64.split(',')[1];
const decoded = atob(base64Content);
expect(decoded).toBe(content);
});
});
});

View File

@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// Supported image MIME types
export const SUPPORTED_IMAGE_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'image/bmp',
];
// Maximum file size in bytes (10MB)
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
// Maximum total size for all images in a single message (20MB)
export const MAX_TOTAL_IMAGE_SIZE = 20 * 1024 * 1024;
export interface ImageAttachment {
id: string;
name: string;
type: string;
size: number;
data: string; // base64 encoded
timestamp: number;
}
/**
* Convert a File or Blob to base64 string
*/
export async function fileToBase64(file: File | Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* Check if a file is a supported image type
*/
export function isSupportedImage(file: File): boolean {
return SUPPORTED_IMAGE_TYPES.includes(file.type);
}
/**
* Check if a file size is within limits
*/
export function isWithinSizeLimit(file: File): boolean {
return file.size <= MAX_IMAGE_SIZE;
}
/**
* Generate a unique ID for an image attachment
*/
export function generateImageId(): string {
return `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Get a human-readable file size
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) {
return '0 B';
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Extract image dimensions from base64 string
*/
export async function getImageDimensions(
base64: string,
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.onerror = reject;
img.src = base64;
});
}
/**
* Create an ImageAttachment from a File
*/
export async function createImageAttachment(
file: File,
): Promise<ImageAttachment | null> {
if (!isSupportedImage(file)) {
console.warn('Unsupported image type:', file.type);
return null;
}
if (!isWithinSizeLimit(file)) {
console.warn('Image file too large:', formatFileSize(file.size));
return null;
}
try {
const base64Data = await fileToBase64(file);
return {
id: generateImageId(),
name: file.name || `image_${Date.now()}`,
type: file.type,
size: file.size,
data: base64Data,
timestamp: Date.now(),
};
} catch (error) {
console.error('Failed to create image attachment:', error);
return null;
}
}
/**
* Get extension from MIME type
*/
export function getExtensionFromMimeType(mimeType: string): string {
const mimeMap: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/bmp': '.bmp',
'image/svg+xml': '.svg',
};
return mimeMap[mimeType] || '.png';
}
/**
* Generate a clean filename for pasted images
*/
export function generatePastedImageName(mimeType: string): string {
const now = new Date();
const timeStr = `${now.getHours().toString().padStart(2, '0')}${now
.getMinutes()
.toString()
.padStart(2, '0')}${now.getSeconds().toString().padStart(2, '0')}`;
const ext = getExtensionFromMimeType(mimeType);
return `pasted_image_${timeStr}${ext}`;
}