Compare commits

..

1 Commits

Author SHA1 Message Date
LaZzyMan
15912892f2 fix: missing error throw in non-Interactive mode 2025-12-30 19:40:24 +08:00
17 changed files with 62 additions and 854 deletions

View File

@@ -771,6 +771,52 @@ describe('runNonInteractive', () => {
);
});
it('should handle API errors in text mode and exit with error code', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT);
setupMetricsMock();
// Simulate an API error event (like 401 unauthorized)
const apiErrorEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: {
error: {
message: '401 Incorrect API key provided',
status: 401,
},
},
};
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([apiErrorEvent]),
);
let thrownError: Error | null = null;
try {
await runNonInteractive(
mockConfig,
mockSettings,
'Test input',
'prompt-id-api-error',
);
// Should not reach here
expect.fail('Expected error to be thrown');
} catch (error) {
thrownError = error as Error;
}
// Should throw with the API error message
expect(thrownError).toBeTruthy();
expect(thrownError?.message).toContain('401');
expect(thrownError?.message).toContain('Incorrect API key provided');
// Verify error was written to stderr
expect(processStderrSpy).toHaveBeenCalled();
const stderrCalls = processStderrSpy.mock.calls;
const errorOutput = stderrCalls.map((call) => call[0]).join('');
expect(errorOutput).toContain('401');
expect(errorOutput).toContain('Incorrect API key provided');
});
it('should handle FatalInputError with custom exit code in JSON format', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
setupMetricsMock();

View File

@@ -308,6 +308,8 @@ export async function runNonInteractive(
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
}
}
}

View File

@@ -20,7 +20,7 @@ import type {
AcpConnectionCallbacks,
} from '../types/connectionTypes.js';
import { AcpMessageHandler } from './acpMessageHandler.js';
import { AcpSessionManager, type PromptContent } from './acpSessionManager.js';
import { AcpSessionManager } from './acpSessionManager.js';
import * as fs from 'node:fs';
/**
@@ -283,12 +283,12 @@ export class AcpConnection {
}
/**
* Send prompt message with support for multimodal content
* Send prompt message
*
* @param prompt - Either a plain text string or array of content items
* @param prompt - Prompt content
* @returns Response
*/
async sendPrompt(prompt: string | PromptContent[]): Promise<AcpResponse> {
async sendPrompt(prompt: string): Promise<AcpResponse> {
return this.sessionManager.sendPrompt(
prompt,
this.child,

View File

@@ -20,13 +20,6 @@ import { AGENT_METHODS } from '../constants/acpSchema.js';
import type { PendingRequest } from '../types/connectionTypes.js';
import type { ChildProcess } from 'child_process';
/**
* 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
@@ -110,29 +103,6 @@ export class AcpSessionManager {
if (child?.stdin) {
const jsonString = JSON.stringify(message);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
// Debug logging for session_prompt messages
if ('method' in message && message.method === 'session/prompt') {
console.log('[ACP] Sending session/prompt message');
const params = (message as AcpRequest).params as {
prompt: PromptContent[];
};
if (params?.prompt) {
console.log('[ACP] Prompt array length:', params.prompt.length);
params.prompt.forEach((item: PromptContent, index: number) => {
if (item.type === 'image') {
console.log(
`[ACP] Item ${index} is image: mimeType=${item.mimeType}, data length=${item.data?.length || 0}`,
);
} else if (item.type === 'text') {
console.log(
`[ACP] Item ${index} is text: "${item.text?.substring(0, 50)}..."`,
);
}
});
}
}
child.stdin.write(jsonString + lineEnding);
}
}
@@ -242,9 +212,9 @@ export class AcpSessionManager {
}
/**
* Send prompt message with support for multimodal content (text and images)
* Send prompt message
*
* @param prompt - Either a plain text string or array of content items
* @param prompt - Prompt content
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
@@ -252,7 +222,7 @@ export class AcpSessionManager {
* @throws Error when there is no active session
*/
async sendPrompt(
prompt: string | PromptContent[],
prompt: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
@@ -261,35 +231,11 @@ 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: promptContent,
prompt: [{ type: 'text', text: prompt }],
},
child,
pendingRequests,

View File

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

View File

@@ -51,9 +51,6 @@ 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();
@@ -71,7 +68,6 @@ export const App: React.FC = () => {
// UI state
const [inputText, setInputText] = useState('');
const [attachedImages, setAttachedImages] = useState<ImageAttachment[]>([]);
const [permissionRequest, setPermissionRequest] = useState<{
options: PermissionOption[];
toolCall: PermissionToolCall;
@@ -247,54 +243,10 @@ 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,
@@ -714,7 +666,6 @@ export const App: React.FC = () => {
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
fileContext={msg.fileContext}
attachments={msg.attachments}
/>
);
}
@@ -863,7 +814,6 @@ export const App: React.FC = () => {
activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext}
contextUsage={contextUsage}
attachedImages={attachedImages}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
@@ -876,8 +826,6 @@ 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} data:; 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}; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
<title>Qwen Code</title>
</head>
<body data-extension-uri="${safeExtensionUri}">

View File

@@ -1,60 +0,0 @@
/**
* @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,8 +22,6 @@ 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;
@@ -44,7 +42,6 @@ interface InputFormProps {
usedTokens: number;
tokenLimit: number;
} | null;
attachedImages?: ImageAttachment[];
onInputChange: (text: string) => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
@@ -57,8 +54,6 @@ 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;
@@ -108,7 +103,6 @@ export const InputForm: React.FC<InputFormProps> = ({
activeSelection,
skipAutoActiveContext,
contextUsage,
attachedImages = [],
onInputChange,
onCompositionStart,
onCompositionEnd,
@@ -120,8 +114,6 @@ export const InputForm: React.FC<InputFormProps> = ({
onToggleSkipAutoActiveContext,
onShowCommandMenu,
onAttachContext,
onPaste,
onRemoveImage,
completionIsOpen,
completionItems,
onCompletionSelect,
@@ -168,7 +160,7 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Banner area */}
<div className="input-banner" />
<div className="relative flex flex-col z-[1]">
<div className="relative flex z-[1]">
{completionIsOpen &&
completionItems &&
completionItems.length > 0 &&
@@ -206,14 +198,8 @@ 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,7 +6,6 @@
import type React from 'react';
import { MessageContent } from './MessageContent.js';
import type { ImageAttachment } from '../../utils/imageUtils.js';
interface FileContext {
fileName: string;
@@ -20,7 +19,6 @@ interface UserMessageProps {
timestamp: number;
onFileClick?: (path: string) => void;
fileContext?: FileContext;
attachments?: ImageAttachment[];
}
export const UserMessage: React.FC<UserMessageProps> = ({
@@ -28,7 +26,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({
timestamp: _timestamp,
onFileClick,
fileContext,
attachments,
}) => {
// Generate display text for file context
const getFileContextDisplay = () => {
@@ -69,24 +66,6 @@ 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,9 +8,6 @@ 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 type { PromptContent } from '../../services/acpSessionManager.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Session message handler
@@ -68,16 +65,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
endLine?: number;
}
| undefined,
data?.attachments as
| Array<{
id: string;
name: string;
type: string;
size: number;
data: string;
timestamp: number;
}>
| undefined,
);
break;
@@ -144,64 +131,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
/**
* Save base64 image to a temporary file
* @param base64Data The base64 encoded image data (with or without data URL prefix)
* @param fileName Original filename
* @returns The path to the saved file or null if failed
*/
private async saveImageToFile(
base64Data: string,
fileName: string,
): Promise<string | null> {
try {
// Get workspace folder
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
console.error('[SessionMessageHandler] No workspace folder found');
return null;
}
// Create temp directory for images (same as CLI)
const tempDir = path.join(
workspaceFolder.uri.fsPath,
'.gemini-clipboard',
);
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// Generate unique filename (same pattern as CLI)
const timestamp = Date.now();
const ext = path.extname(fileName) || '.png';
const tempFileName = `clipboard-${timestamp}${ext}`;
const tempFilePath = path.join(tempDir, 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');
fs.writeFileSync(tempFilePath, buffer);
console.log('[SessionMessageHandler] Saved image to:', tempFilePath);
// Return relative path from workspace root
const relativePath = path.relative(
workspaceFolder.uri.fsPath,
tempFilePath,
);
return relativePath;
} catch (error) {
console.error('[SessionMessageHandler] Failed to save image:', error);
return null;
}
}
/**
* Get current stream content
*/
@@ -303,23 +232,8 @@ 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;
@@ -336,82 +250,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
formattedText = `${contextParts}\n\n${text}`;
}
// 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
const imagePath = await 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,
);
}
}
// 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(
@@ -482,16 +320,15 @@ export class SessionMessageHandler extends BaseMessageHandler {
timestamp: Date.now(),
};
// Store the original message with just text
await this.conversationStore.addMessage(
this.currentConversationId,
userMessage,
);
// Send to WebView with file context and attachments
// Send to WebView
this.sendToWebView({
type: 'message',
data: { ...userMessage, fileContext, attachments },
data: { ...userMessage, fileContext },
});
// Check if agent is connected
@@ -539,8 +376,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
data: { timestamp: Date.now() },
});
// Send multimodal content instead of plain text
await this.agentManager.sendMessage(promptContent);
await this.agentManager.sendMessage(formattedText);
// Save assistant message
if (this.currentStreamContent && this.currentConversationId) {

View File

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

View File

@@ -7,14 +7,11 @@
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;
@@ -42,8 +39,6 @@ export const useMessageSubmit = ({
vscode,
inputText,
setInputText,
attachedImages = [],
clearImages,
inputFieldRef,
isStreaming,
isWaitingForResponse,
@@ -147,7 +142,6 @@ export const useMessageSubmit = ({
text: inputText,
context: context.length > 0 ? context : undefined,
fileContext: fileContextForMessage,
attachments: attachedImages.length > 0 ? attachedImages : undefined,
},
});
@@ -159,15 +153,9 @@ 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

@@ -1,128 +0,0 @@
/**
* @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;
// Check if there are image files in the clipboard
if (hasFiles) {
processingRef.current = true;
event.preventDefault();
event.stopPropagation();
const imageAttachments: ImageAttachment[] = [];
const errors: string[] = [];
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Check if it's an image
if (!file.type.startsWith('image/')) {
continue;
}
// 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,20 +358,6 @@ 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

@@ -1,163 +0,0 @@
/**
* @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

@@ -1,155 +0,0 @@
/**
* @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}`;
}