mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-11 19:39:18 +00:00
Compare commits
2 Commits
feat/vscod
...
chore/rele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b65a1940 | ||
|
|
b950578990 |
127
.github/workflows/release-sdk.yml
vendored
127
.github/workflows/release-sdk.yml
vendored
@@ -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: |-
|
||||
|
||||
75
.github/workflows/release.yml
vendored
75
.github/workflows/release.yml
vendored
@@ -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 \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user