Compare commits

..

2 Commits

Author SHA1 Message Date
mingholy.lmh
37b65a1940 chore: update release workflows using bot PAT 2026-01-06 21:19:03 +08:00
mingholy.lmh
b950578990 chore: update release workflows to improve versioning and safety. 2025-12-30 14:20:04 +08:00
18 changed files with 161 additions and 911 deletions

View File

@@ -34,7 +34,8 @@ on:
default: false
concurrency:
group: '${{ github.workflow }}'
# Serialize all release workflows (CLI + SDK) to avoid racing on `main` pushes.
group: 'release-main'
cancel-in-progress: false
jobs:
@@ -50,7 +51,6 @@ jobs:
packages: 'write'
id-token: 'write'
issues: 'write'
pull-requests: 'write'
outputs:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
@@ -128,12 +128,13 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Set SDK package version (local only)'
- name: 'Set SDK package version'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
# Ensure the package version matches the computed release version.
# This is required for nightly/preview because npm does not allow re-publishing the same version.
# Using --no-git-tag-version because we create tags via GitHub Release, not npm.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Build CLI Bundle'
@@ -168,37 +169,40 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create and switch to a release branch'
- name: 'Create and switch to a release branch (stable only)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'release_branch'
env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}"
git switch -c "${BRANCH_NAME}"
# Make reruns idempotent: reuse an existing remote branch if it already exists.
if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then
git switch "${BRANCH_NAME}"
elif git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then
git fetch origin "${BRANCH_NAME}:${BRANCH_NAME}"
git switch "${BRANCH_NAME}"
else
git switch -c "${BRANCH_NAME}"
fi
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Commit and Push package version (stable only)'
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Commit and Push package version to release branch (stable only)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
# Only persist version bumps after a successful publish.
git add packages/sdk-typescript/package.json package-lock.json
if git diff --staged --quiet; then
echo "No version changes to commit"
@@ -208,9 +212,47 @@ jobs:
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
- name: 'Create GitHub Release and Tag'
- name: 'Check if @qwen-code/sdk version is already published (rerun safety)'
id: 'npm_check'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
set -euo pipefail
if npm view "@qwen-code/sdk@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "@qwen-code/sdk@${RELEASE_VERSION} already exists on npm."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
if: |-
${{ steps.vars.outputs.is_dry_run == 'true' || steps.npm_check.outputs.already_published != 'true' }}
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Check if GitHub Release already exists (rerun safety)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }}
id: 'gh_release_check'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
if gh release view "sdk-typescript-${RELEASE_TAG}" >/dev/null 2>&1; then
echo "already_exists=true" >> "${GITHUB_OUTPUT}"
echo "GitHub Release sdk-typescript-${RELEASE_TAG} already exists."
else
echo "already_exists=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Create GitHub Release and Tag'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.gh_release_check.outputs.already_exists != 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
@@ -236,48 +278,27 @@ jobs:
--generate-notes \
${PRERELEASE_FLAG}
- name: 'Create PR to merge release branch into main'
- name: 'Create release PR for SDK version bump'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'pr'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
run: |-
set -euo pipefail
pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')"
if [[ -z "${pr_url}" ]]; then
pr_url="$(gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--title "chore(release): sdk-typescript ${RELEASE_TAG}" \
--body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")"
pr_exists=$(gh pr list --head "${RELEASE_BRANCH}" --state open --json number --jq 'length')
if [[ "${pr_exists}" != "0" ]]; then
echo "Open PR already exists for ${RELEASE_BRANCH}; skipping creation."
exit 0
fi
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
- name: 'Wait for CI checks to complete'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
echo "Waiting for CI checks to complete..."
gh pr checks "${PR_URL}" --watch --interval 30
- name: 'Enable auto-merge for release PR'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
gh pr merge "${PR_URL}" --merge --auto
gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--title "chore(release): sdk-typescript ${RELEASE_TAG}" \
--body "Automated SDK version bump for ${RELEASE_TAG}."
- name: 'Create Issue on Failure'
if: |-

View File

@@ -38,6 +38,11 @@ on:
type: 'boolean'
default: false
concurrency:
# Serialize all release workflows (CLI + SDK) to avoid racing on `main` pushes.
group: 'release-main'
cancel-in-progress: false
jobs:
release:
runs-on: 'ubuntu-latest'
@@ -150,8 +155,19 @@ jobs:
env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
BRANCH_NAME="release/${RELEASE_TAG}"
git switch -c "${BRANCH_NAME}"
# Make reruns idempotent: reuse an existing remote branch if it already exists.
if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then
git switch "${BRANCH_NAME}"
elif git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then
git fetch origin "${BRANCH_NAME}:${BRANCH_NAME}"
git switch "${BRANCH_NAME}"
else
git switch -c "${BRANCH_NAME}"
fi
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Update package versions'
@@ -191,16 +207,47 @@ jobs:
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Check if @qwen-code/qwen-code version is already published (rerun safety)'
id: 'npm_check'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
set -euo pipefail
if npm view "@qwen-code/qwen-code@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "@qwen-code/qwen-code@${RELEASE_VERSION} already exists on npm."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Publish @qwen-code/qwen-code'
working-directory: 'dist'
if: |-
${{ steps.vars.outputs.is_dry_run == 'true' || steps.npm_check.outputs.already_published != 'true' }}
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create GitHub Release and Tag'
- name: 'Check if GitHub Release already exists (rerun safety)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }}
id: 'gh_release_check'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
echo "already_exists=true" >> "${GITHUB_OUTPUT}"
echo "GitHub Release ${RELEASE_TAG} already exists."
else
echo "already_exists=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Create GitHub Release and Tag'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.gh_release_check.outputs.already_exists != 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
@@ -214,12 +261,34 @@ jobs:
--notes-start-tag "$PREVIOUS_RELEASE_TAG" \
--generate-notes
- name: 'Create release PR for version bump'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
run: |-
set -euo pipefail
pr_exists=$(gh pr list --head "${RELEASE_BRANCH}" --state open --json number --jq 'length')
if [[ "${pr_exists}" != "0" ]]; then
echo "Open PR already exists for ${RELEASE_BRANCH}; skipping creation."
exit 0
fi
gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--title "chore(release): ${RELEASE_TAG}" \
--body "Automated version bump for ${RELEASE_TAG}."
- name: 'Create Issue on Failure'
if: |-
${{ failure() }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }} || "N/A"'
RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}"
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
gh issue create \

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.1.0",
"version": "0.1.1",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

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}`;
}