mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-24 01:36:24 +00:00
Compare commits
22 Commits
feat/exten
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20e38502fe | ||
|
|
1c997bdfff | ||
|
|
635ed2ce96 | ||
|
|
0c229ec9b5 | ||
|
|
5d369c1d99 | ||
|
|
e281b19782 | ||
|
|
3f227b819d | ||
|
|
483cc583ce | ||
|
|
c738b3a2fb | ||
|
|
359ef6dbca | ||
|
|
829ba9c431 | ||
|
|
8d0f785c28 | ||
|
|
6be47fe008 | ||
|
|
29e71a5d7d | ||
|
|
bfe451bb4a | ||
|
|
c143c68656 | ||
|
|
011f3d2320 | ||
|
|
27df0486a3 | ||
|
|
47ee9b5db8 | ||
|
|
605e8709fb | ||
|
|
4a0e55530b | ||
|
|
510d38fe3a |
207
.github/workflows/release-vscode-companion.yml
vendored
Normal file
207
.github/workflows/release-vscode-companion.yml
vendored
Normal file
@@ -0,0 +1,207 @@
|
||||
name: 'Release VSCode IDE Companion'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
|
||||
required: false
|
||||
type: 'string'
|
||||
ref:
|
||||
description: 'The branch or ref (full git sha) to release from.'
|
||||
required: true
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
dry_run:
|
||||
description: 'Run a dry-run of the release process; no branches, vsix packages or GitHub releases will be created.'
|
||||
required: true
|
||||
type: 'boolean'
|
||||
default: true
|
||||
create_preview_release:
|
||||
description: 'Auto apply the preview release tag, input version is ignored.'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
force_skip_tests:
|
||||
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-vscode-companion:
|
||||
runs-on: 'ubuntu-latest'
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set booleans for simplified logic'
|
||||
env:
|
||||
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
|
||||
DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
|
||||
id: 'vars'
|
||||
run: |-
|
||||
is_preview="false"
|
||||
if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
|
||||
is_preview="true"
|
||||
fi
|
||||
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
is_dry_run="false"
|
||||
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
|
||||
is_dry_run="true"
|
||||
fi
|
||||
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
run: |-
|
||||
npm ci
|
||||
|
||||
- name: 'Install VSCE and OVSX'
|
||||
run: |-
|
||||
npm install -g @vscode/vsce
|
||||
npm install -g ovsx
|
||||
|
||||
- name: 'Get the version'
|
||||
id: 'version'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |
|
||||
# Get the base version from package.json regardless of scenario
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [[ "${IS_PREVIEW}" == "true" ]]; then
|
||||
# Generate preview version with timestamp based on actual package version
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}"
|
||||
RELEASE_TAG="preview.${TIMESTAMP}"
|
||||
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=${PREVIEW_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "VSCODE_TAG=preview" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Use specified version or get from package.json
|
||||
if [[ -n "${MANUAL_VERSION}" ]]; then
|
||||
RELEASE_VERSION="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
|
||||
RELEASE_TAG="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
|
||||
else
|
||||
RELEASE_VERSION="${BASE_VERSION}"
|
||||
RELEASE_TAG="${BASE_VERSION}"
|
||||
fi
|
||||
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "VSCODE_TAG=latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Update package version (for preview releases)'
|
||||
if: '${{ steps.vars.outputs.is_preview == ''true'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
run: |-
|
||||
# Update package.json with preview version
|
||||
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |
|
||||
npm run test:ci
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Prepare VSCode Extension'
|
||||
run: |
|
||||
# Build and stage the extension + bundled CLI once.
|
||||
npm --workspace=qwen-code-vscode-ide-companion run prepackage
|
||||
|
||||
- name: 'Package VSIX (dry run)'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
else
|
||||
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
fi
|
||||
|
||||
- name: 'Upload VSIX Artifact (dry run)'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
|
||||
path: 'packages/qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
|
||||
if-no-files-found: 'error'
|
||||
|
||||
- name: 'Publish to Microsoft Marketplace'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
VSCE_PAT: '${{ secrets.VSCE_PAT }}'
|
||||
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
echo "Skipping Microsoft Marketplace for preview release"
|
||||
else
|
||||
vsce publish --pat "${VSCE_PAT}" --tag "${VSCODE_TAG}"
|
||||
fi
|
||||
|
||||
- name: 'Publish to OpenVSX'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
|
||||
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
# For preview releases, publish with preview tag
|
||||
# First package the extension for preview
|
||||
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --pre-release
|
||||
else
|
||||
# Package and publish normally
|
||||
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --tag "${VSCODE_TAG}"
|
||||
fi
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
${{ failure() }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
gh issue create \
|
||||
--title "VSCode IDE Companion Release Failed for ${{ steps.version.outputs.RELEASE_VERSION }} on $(date +'%Y-%m-%d')" \
|
||||
--body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
@@ -5,6 +5,8 @@
|
||||
[](https://nodejs.org/)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15287" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15287" alt="QwenLM%2Fqwen-code | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
**An open-source AI agent that lives in your terminal.**
|
||||
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/users/overview">中文</a> |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
introduction: 'Introduction',
|
||||
'getting-started-extensions': {
|
||||
'getting-start-extensions': {
|
||||
display: 'hidden',
|
||||
},
|
||||
'extension-releasing': {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Qwen Code Extensions
|
||||
|
||||
Qwen Code extensions package prompts, MCP servers, subagents, skills and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable.
|
||||
Qwen Code extensions package prompts, MCP servers, and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable.
|
||||
|
||||
Extensions and plugins from [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/) and [Claude Code Marketplace](https://claudemarketplaces.com/) can be directly installed into Qwen Code.This cross-platform compatibility gives you access to a rich ecosystem of extensions and plugins, dramatically expanding Qwen Code's capabilities without requiring extension authors to maintain separate versions.
|
||||
This cross-platform compatibility gives you access to a rich ecosystem of extensions and plugins, dramatically expanding Qwen Code's capabilities without requiring extension authors to maintain separate versions.
|
||||
|
||||
## Extension management
|
||||
|
||||
@@ -21,7 +21,6 @@ You can manage extensions at runtime within the interactive CLI using `/extensio
|
||||
| `/extensions disable <name> --scope <user\|workspace>` | Disable an extension |
|
||||
| `/extensions update <name>` | Update a specific extension |
|
||||
| `/extensions update --all` | Update all extensions with available updates |
|
||||
| `/extensions detail <name>` | Show details of an extension |
|
||||
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |
|
||||
|
||||
### CLI Extension Management
|
||||
@@ -32,30 +31,26 @@ You can also manage extensions using `qwen extensions` CLI commands. Note that c
|
||||
|
||||
You can install an extension using `qwen extensions install` from multiple sources:
|
||||
|
||||
#### From Gemini CLI Extensions Marketplace
|
||||
|
||||
Qwen Code fully supports extensions from the [Gemini CLI Extensions Marketplace](https://geminicli.com/extensions/). Simply install them using the git URL:
|
||||
|
||||
```bash
|
||||
qwen extensions install <gemini-cli-extension-url>
|
||||
```
|
||||
|
||||
Gemini extensions are automatically converted to Qwen Code format during installation:
|
||||
|
||||
- `gemini-extension.json` is converted to `qwen-extension.json`
|
||||
- TOML command files are automatically migrated to Markdown format
|
||||
- MCP servers, context files, and settings are preserved
|
||||
|
||||
#### From Claude Code Marketplace
|
||||
|
||||
Qwen Code also supports plugins from the [Claude Code Marketplace](https://claudemarketplaces.com/). Install from a marketplace and choose a plugin:
|
||||
Qwen Code also supports plugins from the [Claude Code Marketplace](https://claudemarketplaces.com/). Install them using the marketplace URL format:
|
||||
|
||||
```bash
|
||||
qwen extensions install <marketplace-name>
|
||||
# or
|
||||
qwen extensions install <marketplace-github-url>
|
||||
```
|
||||
|
||||
If you want to install a specific pulgin, you can use the format with plugin name:
|
||||
|
||||
```bash
|
||||
qwen extensions install <marketplace-name>:<plugin-name>
|
||||
# or
|
||||
qwen extensions install <marketplace-github-url>:<plugin-name>
|
||||
```
|
||||
|
||||
For example, to install the `prompts.chat` plugin from the [f/awesome-chatgpt-prompts](https://claudemarketplaces.com/plugins/f-awesome-chatgpt-prompts) marketplace:
|
||||
|
||||
```bash
|
||||
qwen extensions install f/awesome-chatgpt-prompts:prompts.chat
|
||||
# or
|
||||
qwen extensions install https://github.com/f/awesome-chatgpt-prompts:prompts.chat
|
||||
qwen extensions install <claude-code-marketplace-url>:<plugin-name>
|
||||
```
|
||||
|
||||
Claude plugins are automatically converted to Qwen Code format during installation:
|
||||
@@ -65,36 +60,8 @@ Claude plugins are automatically converted to Qwen Code format during installati
|
||||
- Skill configurations are converted to Qwen skill format
|
||||
- Tool mappings are automatically handled
|
||||
|
||||
You can quickly browse available extensions from different marketplaces using the `/extensions explore` command:
|
||||
|
||||
```bash
|
||||
# Open Gemini CLI Extensions marketplace
|
||||
/extensions explore Gemini
|
||||
|
||||
# Open Claude Code marketplace
|
||||
/extensions explore ClaudeCode
|
||||
```
|
||||
|
||||
This command opens the respective marketplace in your default browser, allowing you to discover new extensions to enhance your Qwen Code experience.
|
||||
|
||||
> **Cross-Platform Compatibility**: This allows you to leverage the rich extension ecosystems from both Gemini CLI and Claude Code, dramatically expanding the available functionality for Qwen Code users.
|
||||
|
||||
#### From Gemini CLI Extensions
|
||||
|
||||
Qwen Code fully supports extensions from the [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/). Simply install them using the git URL:
|
||||
|
||||
```bash
|
||||
qwen extensions install <gemini-cli-extension-github-url>
|
||||
# or
|
||||
qwen extensions install <owner>/<repo>
|
||||
```
|
||||
|
||||
Gemini extensions are automatically converted to Qwen Code format during installation:
|
||||
|
||||
- `gemini-extension.json` is converted to `qwen-extension.json`
|
||||
- TOML command files are automatically migrated to Markdown format
|
||||
- MCP servers, context files, and settings are preserved
|
||||
|
||||
#### From Git Repository
|
||||
|
||||
```bash
|
||||
@@ -141,6 +108,20 @@ You can update all extensions with:
|
||||
qwen extensions update --all
|
||||
```
|
||||
|
||||
### Exploring Extension Marketplaces
|
||||
|
||||
You can quickly browse available extensions from different marketplaces using the `/extensions explore` command:
|
||||
|
||||
```bash
|
||||
# Open Gemini CLI Extensions marketplace
|
||||
/extensions explore Gemini
|
||||
|
||||
# Open Claude Code marketplace
|
||||
/extensions explore ClaudeCode
|
||||
```
|
||||
|
||||
This command opens the respective marketplace in your default browser, allowing you to discover new extensions to enhance your Qwen Code experience.
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Qwen Code looks for extensions in `<home>/.qwen/extensions`
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -3879,6 +3879,7 @@
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz",
|
||||
"integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -17348,7 +17349,6 @@
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"command-exists": "^1.2.9",
|
||||
@@ -17364,7 +17364,6 @@
|
||||
"ink-spinner": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"open": "^10.1.2",
|
||||
"prompts": "^2.4.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.1.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"command-exists": "^1.2.9",
|
||||
@@ -56,7 +55,6 @@
|
||||
"ink-spinner": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"open": "^10.1.2",
|
||||
"prompts": "^2.4.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.1.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
|
||||
@@ -5,16 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
extensionConsentString,
|
||||
requestConsentOrFail,
|
||||
requestChoicePluginNonInteractive,
|
||||
} from './consent.js';
|
||||
import type {
|
||||
ExtensionConfig,
|
||||
ClaudeMarketplaceConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import prompts from 'prompts';
|
||||
import { extensionConsentString, requestConsentOrFail } from './consent.js';
|
||||
import type { ExtensionConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('../../i18n/index.js', () => ({
|
||||
t: vi.fn((str: string, params?: Record<string, string>) => {
|
||||
@@ -28,8 +20,6 @@ vi.mock('../../i18n/index.js', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('prompts');
|
||||
|
||||
describe('extensionConsentString', () => {
|
||||
it('should include extension name', () => {
|
||||
const config: ExtensionConfig = {
|
||||
@@ -251,72 +241,3 @@ describe('requestConsentOrFail', () => {
|
||||
expect(mockRequestConsent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestChoicePluginNonInteractive', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should throw error when plugins array is empty', async () => {
|
||||
const marketplace: ClaudeMarketplaceConfig = {
|
||||
name: 'test-marketplace',
|
||||
owner: { name: 'Test Owner', email: 'test@example.com' },
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestChoicePluginNonInteractive(marketplace),
|
||||
).rejects.toThrow('No plugins available in this marketplace.');
|
||||
});
|
||||
|
||||
it('should return selected plugin name', async () => {
|
||||
const marketplace: ClaudeMarketplaceConfig = {
|
||||
name: 'test-marketplace',
|
||||
owner: { name: 'Test Owner', email: 'test@example.com' },
|
||||
plugins: [
|
||||
{
|
||||
name: 'plugin1',
|
||||
description: 'Plugin 1',
|
||||
version: '1.0.0',
|
||||
source: 'src1',
|
||||
},
|
||||
{
|
||||
name: 'plugin2',
|
||||
description: 'Plugin 2',
|
||||
version: '1.0.0',
|
||||
source: 'src2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(prompts).mockResolvedValueOnce({ plugin: 'plugin2' });
|
||||
|
||||
const result = await requestChoicePluginNonInteractive(marketplace);
|
||||
|
||||
expect(result).toBe('plugin2');
|
||||
expect(prompts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'select',
|
||||
name: 'plugin',
|
||||
choices: expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'plugin1' }),
|
||||
expect.objectContaining({ value: 'plugin2' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when selection is cancelled', async () => {
|
||||
const marketplace: ClaudeMarketplaceConfig = {
|
||||
name: 'test-marketplace',
|
||||
owner: { name: 'Test Owner', email: 'test@example.com' },
|
||||
plugins: [{ name: 'plugin1', version: '1.0.0', source: 'src1' }],
|
||||
};
|
||||
|
||||
vi.mocked(prompts).mockResolvedValueOnce({ plugin: undefined });
|
||||
|
||||
await expect(
|
||||
requestChoicePluginNonInteractive(marketplace),
|
||||
).rejects.toThrow('Plugin selection cancelled.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
ClaudeMarketplaceConfig,
|
||||
ExtensionConfig,
|
||||
ExtensionRequestOptions,
|
||||
SkillConfig,
|
||||
@@ -7,7 +6,6 @@ import type {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import chalk from 'chalk';
|
||||
import prompts from 'prompts';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
@@ -29,49 +27,6 @@ export async function requestConsentNonInteractive(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests plugin selection from the user in non-interactive mode.
|
||||
* Displays an interactive list with arrow key navigation.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param marketplace The marketplace config containing available plugins.
|
||||
* @returns The name of the selected plugin.
|
||||
*/
|
||||
export async function requestChoicePluginNonInteractive(
|
||||
marketplace: ClaudeMarketplaceConfig,
|
||||
): Promise<string> {
|
||||
const plugins = marketplace.plugins;
|
||||
|
||||
if (plugins.length === 0) {
|
||||
throw new Error(t('No plugins available in this marketplace.'));
|
||||
}
|
||||
|
||||
// Build choices for prompts select
|
||||
|
||||
const choices = plugins.map((plugin) => ({
|
||||
title: chalk.green(chalk.bold(`[${plugin.name}]`)),
|
||||
value: plugin.name,
|
||||
}));
|
||||
|
||||
const response = await prompts({
|
||||
type: 'select',
|
||||
name: 'plugin',
|
||||
message: t('Select a plugin to install from marketplace "{{name}}":', {
|
||||
name: marketplace.name,
|
||||
}),
|
||||
choices,
|
||||
initial: 0,
|
||||
});
|
||||
|
||||
// Handle cancellation (Ctrl+C)
|
||||
if (response.plugin === undefined) {
|
||||
throw new Error(t('Plugin selection cancelled.'));
|
||||
}
|
||||
|
||||
return response.plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, in interactive mode.
|
||||
*
|
||||
|
||||
@@ -35,7 +35,6 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
vi.mock('./consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
requestConsentOrFail: mockRequestConsentOrFail,
|
||||
requestChoicePluginNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
|
||||
@@ -16,7 +16,6 @@ import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
requestConsentOrFail,
|
||||
requestConsentNonInteractive,
|
||||
requestChoicePluginNonInteractive,
|
||||
} from './consent.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
@@ -55,7 +54,6 @@ export async function handleInstall(args: InstallArgs) {
|
||||
loadSettings(workspaceDir).merged,
|
||||
),
|
||||
requestConsent,
|
||||
requestChoicePlugin: requestChoicePluginNonInteractive,
|
||||
});
|
||||
await extensionManager.refreshCache();
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ vi.mock('../../config/trustedFolders.js', () => ({
|
||||
vi.mock('./consent.js', () => ({
|
||||
requestConsentOrFail: vi.fn(),
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
requestChoicePluginNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('getExtensionManager', () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
requestConsentOrFail,
|
||||
requestConsentNonInteractive,
|
||||
requestChoicePluginNonInteractive,
|
||||
} from './consent.js';
|
||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
import * as os from 'node:os';
|
||||
@@ -23,7 +22,6 @@ export async function getExtensionManager(): Promise<ExtensionManager> {
|
||||
null,
|
||||
requestConsentNonInteractive,
|
||||
),
|
||||
requestChoicePlugin: requestChoicePluginNonInteractive,
|
||||
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
|
||||
});
|
||||
await extensionManager.refreshCache();
|
||||
|
||||
@@ -139,7 +139,7 @@ export const addCommand: CommandModule = {
|
||||
describe: 'Add a server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
|
||||
.usage('Usage: qwen mcp add [options] <name> <commandOrUrl> [args...]')
|
||||
.parserConfiguration({
|
||||
'unknown-options-as-args': true, // Pass unknown options as server args
|
||||
'populate--': true, // Populate server args after -- separator
|
||||
|
||||
@@ -507,19 +507,6 @@ export default {
|
||||
'Manage extension settings.': 'Erweiterungseinstellungen verwalten.',
|
||||
'You need to specify a command (set or list).':
|
||||
'Sie müssen einen Befehl angeben (set oder list).',
|
||||
// ============================================================================
|
||||
// Plugin Choice / Marketplace
|
||||
// ============================================================================
|
||||
'No plugins available in this marketplace.':
|
||||
'In diesem Marktplatz sind keine Plugins verfügbar.',
|
||||
'Select a plugin to install from marketplace "{{name}}":':
|
||||
'Wählen Sie ein Plugin zur Installation aus Marktplatz "{{name}}":',
|
||||
'Plugin selection cancelled.': 'Plugin-Auswahl abgebrochen.',
|
||||
'Select a plugin from "{{name}}"': 'Plugin aus "{{name}}" auswählen',
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
|
||||
'Verwenden Sie ↑↓ oder j/k zum Navigieren, Enter zum Auswählen, Escape zum Abbrechen',
|
||||
'{{count}} more above': '{{count}} weitere oben',
|
||||
'{{count}} more below': '{{count}} weitere unten',
|
||||
'manage IDE integration': 'IDE-Integration verwalten',
|
||||
'check status of IDE integration': 'Status der IDE-Integration prüfen',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
|
||||
@@ -515,19 +515,6 @@ export default {
|
||||
'Manage extension settings.': 'Manage extension settings.',
|
||||
'You need to specify a command (set or list).':
|
||||
'You need to specify a command (set or list).',
|
||||
// ============================================================================
|
||||
// Plugin Choice / Marketplace
|
||||
// ============================================================================
|
||||
'No plugins available in this marketplace.':
|
||||
'No plugins available in this marketplace.',
|
||||
'Select a plugin to install from marketplace "{{name}}":':
|
||||
'Select a plugin to install from marketplace "{{name}}":',
|
||||
'Plugin selection cancelled.': 'Plugin selection cancelled.',
|
||||
'Select a plugin from "{{name}}"': 'Select a plugin from "{{name}}"',
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel',
|
||||
'{{count}} more above': '{{count}} more above',
|
||||
'{{count}} more below': '{{count}} more below',
|
||||
'manage IDE integration': 'manage IDE integration',
|
||||
'check status of IDE integration': 'check status of IDE integration',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
|
||||
@@ -519,19 +519,6 @@ export default {
|
||||
'Manage extension settings.': 'Управление настройками расширений.',
|
||||
'You need to specify a command (set or list).':
|
||||
'Необходимо указать команду (set или list).',
|
||||
// ============================================================================
|
||||
// Plugin Choice / Marketplace
|
||||
// ============================================================================
|
||||
'No plugins available in this marketplace.':
|
||||
'В этом маркетплейсе нет доступных плагинов.',
|
||||
'Select a plugin to install from marketplace "{{name}}":':
|
||||
'Выберите плагин для установки из маркетплейса "{{name}}":',
|
||||
'Plugin selection cancelled.': 'Выбор плагина отменён.',
|
||||
'Select a plugin from "{{name}}"': 'Выберите плагин из "{{name}}"',
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
|
||||
'Используйте ↑↓ или j/k для навигации, Enter для выбора, Escape для отмены',
|
||||
'{{count}} more above': 'ещё {{count}} выше',
|
||||
'{{count}} more below': 'ещё {{count}} ниже',
|
||||
'manage IDE integration': 'Управление интеграцией с IDE',
|
||||
'check status of IDE integration': 'Проверить статус интеграции с IDE',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
|
||||
@@ -490,18 +490,6 @@ export default {
|
||||
'Manage extension settings.': '管理扩展设置。',
|
||||
'You need to specify a command (set or list).':
|
||||
'您需要指定命令(set 或 list)。',
|
||||
// ============================================================================
|
||||
// Plugin Choice / Marketplace
|
||||
// ============================================================================
|
||||
'No plugins available in this marketplace.': '此市场中没有可用的插件。',
|
||||
'Select a plugin to install from marketplace "{{name}}":':
|
||||
'从市场 "{{name}}" 中选择要安装的插件:',
|
||||
'Plugin selection cancelled.': '插件选择已取消。',
|
||||
'Select a plugin from "{{name}}"': '从 "{{name}}" 中选择插件',
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
|
||||
'使用 ↑↓ 或 j/k 导航,回车选择,Esc 取消',
|
||||
'{{count}} more above': '上方还有 {{count}} 项',
|
||||
'{{count}} more below': '下方还有 {{count}} 项',
|
||||
'manage IDE integration': '管理 IDE 集成',
|
||||
'check status of IDE integration': '检查 IDE 集成状态',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
|
||||
@@ -39,6 +39,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { insightCommand } from '../ui/commands/insightCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
@@ -88,6 +89,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
insightCommand,
|
||||
];
|
||||
|
||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
|
||||
324
packages/cli/src/services/insight/generators/DataProcessor.ts
Normal file
324
packages/cli/src/services/insight/generators/DataProcessor.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { read as readJsonlFile } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
InsightData,
|
||||
HeatMapData,
|
||||
TokenUsageData,
|
||||
AchievementData,
|
||||
StreakData,
|
||||
} from '../types/StaticInsightTypes.js';
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class DataProcessor {
|
||||
// Helper function to format date as YYYY-MM-DD
|
||||
private formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Calculate streaks from activity dates
|
||||
private calculateStreaks(dates: string[]): StreakData {
|
||||
if (dates.length === 0) {
|
||||
return { currentStreak: 0, longestStreak: 0, dates: [] };
|
||||
}
|
||||
|
||||
// Convert string dates to Date objects and sort them
|
||||
const dateObjects = dates.map((dateStr) => new Date(dateStr));
|
||||
dateObjects.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
let currentStreak = 1;
|
||||
let maxStreak = 1;
|
||||
let currentDate = new Date(dateObjects[0]);
|
||||
currentDate.setHours(0, 0, 0, 0); // Normalize to start of day
|
||||
|
||||
for (let i = 1; i < dateObjects.length; i++) {
|
||||
const nextDate = new Date(dateObjects[i]);
|
||||
nextDate.setHours(0, 0, 0, 0); // Normalize to start of day
|
||||
|
||||
// Calculate difference in days
|
||||
const diffDays = Math.floor(
|
||||
(nextDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (diffDays === 1) {
|
||||
// Consecutive day
|
||||
currentStreak++;
|
||||
maxStreak = Math.max(maxStreak, currentStreak);
|
||||
} else if (diffDays > 1) {
|
||||
// Gap in streak
|
||||
currentStreak = 1;
|
||||
}
|
||||
// If diffDays === 0, same day, so streak continues
|
||||
|
||||
currentDate = nextDate;
|
||||
}
|
||||
|
||||
// Check if the streak is still ongoing (if last activity was yesterday or today)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (
|
||||
currentDate.getTime() === today.getTime() ||
|
||||
currentDate.getTime() === yesterday.getTime()
|
||||
) {
|
||||
// The streak might still be active, so we don't reset it
|
||||
}
|
||||
|
||||
return {
|
||||
currentStreak,
|
||||
longestStreak: maxStreak,
|
||||
dates,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate achievements based on user behavior
|
||||
private calculateAchievements(
|
||||
activeHours: { [hour: number]: number },
|
||||
heatmap: HeatMapData,
|
||||
_tokenUsage: TokenUsageData,
|
||||
): AchievementData[] {
|
||||
const achievements: AchievementData[] = [];
|
||||
|
||||
// Total activities
|
||||
const totalActivities = Object.values(heatmap).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
|
||||
// Total sessions
|
||||
const totalSessions = Object.keys(heatmap).length;
|
||||
|
||||
// Calculate percentage of activity per hour
|
||||
const totalHourlyActivity = Object.values(activeHours).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalHourlyActivity > 0) {
|
||||
// Midnight debugger: 20% of sessions happen between 12AM-5AM
|
||||
const midnightActivity =
|
||||
(activeHours[0] || 0) +
|
||||
(activeHours[1] || 0) +
|
||||
(activeHours[2] || 0) +
|
||||
(activeHours[3] || 0) +
|
||||
(activeHours[4] || 0) +
|
||||
(activeHours[5] || 0);
|
||||
|
||||
if (midnightActivity / totalHourlyActivity >= 0.2) {
|
||||
achievements.push({
|
||||
id: 'midnight-debugger',
|
||||
name: 'Midnight Debugger',
|
||||
description: '20% of your sessions happen between 12AM-5AM',
|
||||
});
|
||||
}
|
||||
|
||||
// Morning coder: 20% of sessions happen between 6AM-9AM
|
||||
const morningActivity =
|
||||
(activeHours[6] || 0) +
|
||||
(activeHours[7] || 0) +
|
||||
(activeHours[8] || 0) +
|
||||
(activeHours[9] || 0);
|
||||
|
||||
if (morningActivity / totalHourlyActivity >= 0.2) {
|
||||
achievements.push({
|
||||
id: 'morning-coder',
|
||||
name: 'Morning Coder',
|
||||
description: '20% of your sessions happen between 6AM-9AM',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Patient king: average conversation length >= 10 exchanges
|
||||
if (totalSessions > 0) {
|
||||
const avgExchanges = totalActivities / totalSessions;
|
||||
if (avgExchanges >= 10) {
|
||||
achievements.push({
|
||||
id: 'patient-king',
|
||||
name: 'Patient King',
|
||||
description: 'Your average conversation length is 10+ exchanges',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Quick finisher: 70% of sessions have <= 2 exchanges
|
||||
let quickSessions = 0;
|
||||
// Since we don't have per-session exchange counts easily available,
|
||||
// we'll estimate based on the distribution of activities
|
||||
if (totalSessions > 0) {
|
||||
// This is a simplified calculation - in a real implementation,
|
||||
// we'd need to count exchanges per session
|
||||
const avgPerSession = totalActivities / totalSessions;
|
||||
if (avgPerSession <= 2) {
|
||||
// Estimate based on low average
|
||||
quickSessions = Math.floor(totalSessions * 0.7);
|
||||
}
|
||||
|
||||
if (quickSessions / totalSessions >= 0.7) {
|
||||
achievements.push({
|
||||
id: 'quick-finisher',
|
||||
name: 'Quick Finisher',
|
||||
description: '70% of your sessions end in 2 exchanges or fewer',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Explorer: for users with insufficient data or default
|
||||
if (achievements.length === 0) {
|
||||
achievements.push({
|
||||
id: 'explorer',
|
||||
name: 'Explorer',
|
||||
description: 'Getting started with Qwen Code',
|
||||
});
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
// Process chat files from all projects in the base directory and generate insights
|
||||
async generateInsights(baseDir: string): Promise<InsightData> {
|
||||
// Initialize data structures
|
||||
const heatmap: HeatMapData = {};
|
||||
const tokenUsage: TokenUsageData = {};
|
||||
const activeHours: { [hour: number]: number } = {};
|
||||
const sessionStartTimes: { [sessionId: string]: Date } = {};
|
||||
const sessionEndTimes: { [sessionId: string]: Date } = {};
|
||||
|
||||
try {
|
||||
// Get all project directories in the base directory
|
||||
const projectDirs = await fs.readdir(baseDir);
|
||||
|
||||
// Process each project directory
|
||||
for (const projectDir of projectDirs) {
|
||||
const projectPath = path.join(baseDir, projectDir);
|
||||
const stats = await fs.stat(projectPath);
|
||||
|
||||
// Only process if it's a directory
|
||||
if (stats.isDirectory()) {
|
||||
const chatsDir = path.join(projectPath, 'chats');
|
||||
|
||||
let chatFiles: string[] = [];
|
||||
try {
|
||||
// Get all chat files in the chats directory
|
||||
const files = await fs.readdir(chatsDir);
|
||||
chatFiles = files.filter((file) => file.endsWith('.jsonl'));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.log(
|
||||
`Error reading chats directory for project ${projectDir}: ${error}`,
|
||||
);
|
||||
}
|
||||
// Continue to next project if chats directory doesn't exist
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process each chat file in this project
|
||||
for (const file of chatFiles) {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
const records = await readJsonlFile<ChatRecord>(filePath);
|
||||
|
||||
// Process each record
|
||||
for (const record of records) {
|
||||
const timestamp = new Date(record.timestamp);
|
||||
const dateKey = this.formatDate(timestamp);
|
||||
const hour = timestamp.getHours();
|
||||
|
||||
// Update heatmap (count of interactions per day)
|
||||
heatmap[dateKey] = (heatmap[dateKey] || 0) + 1;
|
||||
|
||||
// Update active hours
|
||||
activeHours[hour] = (activeHours[hour] || 0) + 1;
|
||||
|
||||
// Update token usage
|
||||
if (record.usageMetadata) {
|
||||
const usage = tokenUsage[dateKey] || {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
usage.input += record.usageMetadata.promptTokenCount || 0;
|
||||
usage.output += record.usageMetadata.candidatesTokenCount || 0;
|
||||
usage.total += record.usageMetadata.totalTokenCount || 0;
|
||||
|
||||
tokenUsage[dateKey] = usage;
|
||||
}
|
||||
|
||||
// Track session times
|
||||
if (!sessionStartTimes[record.sessionId]) {
|
||||
sessionStartTimes[record.sessionId] = timestamp;
|
||||
}
|
||||
sessionEndTimes[record.sessionId] = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// Base directory doesn't exist, return empty insights
|
||||
console.log(`Base directory does not exist: ${baseDir}`);
|
||||
} else {
|
||||
console.log(`Error reading base directory: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate streak data
|
||||
const streakData = this.calculateStreaks(Object.keys(heatmap));
|
||||
|
||||
// Calculate longest work session
|
||||
let longestWorkDuration = 0;
|
||||
let longestWorkDate: string | null = null;
|
||||
for (const sessionId in sessionStartTimes) {
|
||||
const start = sessionStartTimes[sessionId];
|
||||
const end = sessionEndTimes[sessionId];
|
||||
const durationMinutes = Math.round(
|
||||
(end.getTime() - start.getTime()) / (1000 * 60),
|
||||
);
|
||||
|
||||
if (durationMinutes > longestWorkDuration) {
|
||||
longestWorkDuration = durationMinutes;
|
||||
longestWorkDate = this.formatDate(start);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate latest active time
|
||||
let latestActiveTime: string | null = null;
|
||||
let latestTimestamp = new Date(0);
|
||||
for (const dateStr in heatmap) {
|
||||
const date = new Date(dateStr);
|
||||
if (date > latestTimestamp) {
|
||||
latestTimestamp = date;
|
||||
latestActiveTime = date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate achievements
|
||||
const achievements = this.calculateAchievements(
|
||||
activeHours,
|
||||
heatmap,
|
||||
tokenUsage,
|
||||
);
|
||||
|
||||
return {
|
||||
heatmap,
|
||||
tokenUsage,
|
||||
currentStreak: streakData.currentStreak,
|
||||
longestStreak: streakData.longestStreak,
|
||||
longestWorkDate,
|
||||
longestWorkDuration,
|
||||
activeHours,
|
||||
latestActiveTime,
|
||||
achievements,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { DataProcessor } from './DataProcessor.js';
|
||||
import { TemplateRenderer } from './TemplateRenderer.js';
|
||||
import type { InsightData } from '../types/StaticInsightTypes.js';
|
||||
|
||||
export class StaticInsightGenerator {
|
||||
private dataProcessor: DataProcessor;
|
||||
private templateRenderer: TemplateRenderer;
|
||||
|
||||
constructor() {
|
||||
this.dataProcessor = new DataProcessor();
|
||||
this.templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
// Ensure the output directory exists
|
||||
private async ensureOutputDirectory(): Promise<string> {
|
||||
const outputDir = path.join(os.homedir(), '.qwen', 'insights');
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
// Generate the static insight HTML file
|
||||
async generateStaticInsight(baseDir: string): Promise<string> {
|
||||
try {
|
||||
// Process data
|
||||
console.log('Processing insight data...');
|
||||
const insights: InsightData =
|
||||
await this.dataProcessor.generateInsights(baseDir);
|
||||
|
||||
// Render HTML
|
||||
console.log('Rendering HTML template...');
|
||||
const html = await this.templateRenderer.renderInsightHTML(insights);
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = await this.ensureOutputDirectory();
|
||||
const outputPath = path.join(outputDir, 'insight.html');
|
||||
|
||||
// Write the HTML file
|
||||
console.log(`Writing HTML file to: ${outputPath}`);
|
||||
await fs.writeFile(outputPath, html, 'utf-8');
|
||||
|
||||
console.log('Static insight generation completed successfully');
|
||||
return outputPath;
|
||||
} catch (error) {
|
||||
console.log(`Error generating static insight: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { InsightData } from '../types/StaticInsightTypes.js';
|
||||
|
||||
export class TemplateRenderer {
|
||||
private templateDir: string;
|
||||
|
||||
constructor() {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
this.templateDir = path.join(__dirname, '..', 'templates');
|
||||
}
|
||||
|
||||
// Load template files
|
||||
private async loadTemplate(): Promise<string> {
|
||||
const templatePath = path.join(this.templateDir, 'insight-template.html');
|
||||
return await fs.readFile(templatePath, 'utf-8');
|
||||
}
|
||||
|
||||
private async loadStyles(): Promise<string> {
|
||||
const stylesPath = path.join(this.templateDir, 'styles', 'base.css');
|
||||
return await fs.readFile(stylesPath, 'utf-8');
|
||||
}
|
||||
|
||||
private async loadScripts(): Promise<string> {
|
||||
const scriptsPath = path.join(
|
||||
this.templateDir,
|
||||
'scripts',
|
||||
'insight-app.js',
|
||||
);
|
||||
return await fs.readFile(scriptsPath, 'utf-8');
|
||||
}
|
||||
|
||||
// Render the complete HTML file
|
||||
async renderInsightHTML(insights: InsightData): Promise<string> {
|
||||
const template = await this.loadTemplate();
|
||||
const styles = await this.loadStyles();
|
||||
const scripts = await this.loadScripts();
|
||||
|
||||
// Replace all placeholders
|
||||
let html = template;
|
||||
html = html.replace('{{STYLES_PLACEHOLDER}}', styles);
|
||||
html = html.replace('{{DATA_PLACEHOLDER}}', JSON.stringify(insights));
|
||||
html = html.replace('{{SCRIPTS_PLACEHOLDER}}', scripts);
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Qwen Code Insights</title>
|
||||
<style>
|
||||
{{STYLES_PLACEHOLDER}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen" id="container">
|
||||
<div class="mx-auto max-w-6xl px-6 py-10 md:py-12">
|
||||
<header class="mb-8 space-y-3 text-center">
|
||||
<p
|
||||
class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500"
|
||||
>
|
||||
Insights
|
||||
</p>
|
||||
<h1 class="text-3xl font-semibold text-slate-900 md:text-4xl">
|
||||
Qwen Code Insights
|
||||
</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
Your personalized coding journey and patterns
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- React App Mount Point -->
|
||||
<div id="react-root"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React CDN -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- CDN Libraries -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
|
||||
<!-- Application Data -->
|
||||
<script type="text/babel">
|
||||
window.INSIGHT_DATA = {{DATA_PLACEHOLDER}};
|
||||
{{SCRIPTS_PLACEHOLDER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,510 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable no-undef */
|
||||
// React-based implementation of the insight app
|
||||
// Converts the vanilla JavaScript implementation to React
|
||||
|
||||
const { useState, useRef, useEffect } = React;
|
||||
|
||||
// Main App Component
|
||||
function InsightApp({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-center text-slate-600">
|
||||
No insight data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DashboardCards insights={data} />
|
||||
<HeatmapSection heatmap={data.heatmap} />
|
||||
<TokenUsageSection tokenUsage={data.tokenUsage} />
|
||||
<AchievementsSection achievements={data.achievements} />
|
||||
<ExportButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dashboard Cards Component
|
||||
function DashboardCards({ insights }) {
|
||||
const cardClass = 'glass-card p-6';
|
||||
const sectionTitleClass =
|
||||
'text-lg font-semibold tracking-tight text-slate-900';
|
||||
const captionClass = 'text-sm font-medium text-slate-500';
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3 md:gap-6">
|
||||
<StreakCard
|
||||
currentStreak={insights.currentStreak}
|
||||
longestStreak={insights.longestStreak}
|
||||
cardClass={cardClass}
|
||||
captionClass={captionClass}
|
||||
/>
|
||||
<ActiveHoursChart
|
||||
activeHours={insights.activeHours}
|
||||
cardClass={cardClass}
|
||||
sectionTitleClass={sectionTitleClass}
|
||||
/>
|
||||
<WorkSessionCard
|
||||
longestWorkDuration={insights.longestWorkDuration}
|
||||
longestWorkDate={insights.longestWorkDate}
|
||||
latestActiveTime={insights.latestActiveTime}
|
||||
cardClass={cardClass}
|
||||
sectionTitleClass={sectionTitleClass}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Streak Card Component
|
||||
function StreakCard({ currentStreak, longestStreak, cardClass, captionClass }) {
|
||||
return (
|
||||
<div className={`${cardClass} h-full`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className={captionClass}>Current Streak</p>
|
||||
<p className="mt-1 text-4xl font-bold text-slate-900">
|
||||
{currentStreak}
|
||||
<span className="ml-2 text-base font-semibold text-slate-500">
|
||||
days
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700">
|
||||
Longest {longestStreak}d
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active Hours Chart Component
|
||||
function ActiveHoursChart({ activeHours, cardClass, sectionTitleClass }) {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
|
||||
const canvas = chartRef.current;
|
||||
if (!canvas || !window.Chart) return;
|
||||
|
||||
const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`);
|
||||
const data = labels.map((_, i) => activeHours[i] || 0);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
chartInstance.current = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Activity per Hour',
|
||||
data,
|
||||
backgroundColor: 'rgba(52, 152, 219, 0.7)',
|
||||
borderColor: 'rgba(52, 152, 219, 1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [activeHours]);
|
||||
|
||||
return (
|
||||
<div className={`${cardClass} h-full`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={sectionTitleClass}>Active Hours</h3>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
24h
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 h-56 w-full">
|
||||
<canvas ref={chartRef} className="w-full h-56" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Work Session Card Component
|
||||
function WorkSessionCard({
|
||||
longestWorkDuration,
|
||||
longestWorkDate,
|
||||
latestActiveTime,
|
||||
cardClass,
|
||||
sectionTitleClass,
|
||||
}) {
|
||||
return (
|
||||
<div className={`${cardClass} h-full space-y-3`}>
|
||||
<h3 className={sectionTitleClass}>Work Session</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-700">
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Longest
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{longestWorkDuration}m
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Date
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{longestWorkDate || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 rounded-xl bg-slate-50 px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Last Active
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{latestActiveTime || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Heatmap Section Component
|
||||
function HeatmapSection({ heatmap }) {
|
||||
const cardClass = 'glass-card p-6';
|
||||
const sectionTitleClass =
|
||||
'text-lg font-semibold tracking-tight text-slate-900';
|
||||
|
||||
return (
|
||||
<div className={`${cardClass} mt-4 space-y-4 md:mt-6`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={sectionTitleClass}>Activity Heatmap</h3>
|
||||
<span className="text-xs font-semibold text-slate-500">Past year</span>
|
||||
</div>
|
||||
<div className="heatmap-container">
|
||||
<div className="min-w-[720px] rounded-xl border border-slate-100 bg-white/70 p-4 shadow-inner shadow-slate-100">
|
||||
<ActivityHeatmap heatmapData={heatmap} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Activity Heatmap Component
|
||||
function ActivityHeatmap({ heatmapData }) {
|
||||
const width = 1000;
|
||||
const height = 150;
|
||||
const cellSize = 14;
|
||||
const cellPadding = 2;
|
||||
|
||||
const today = new Date();
|
||||
const oneYearAgo = new Date(today);
|
||||
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
||||
|
||||
// Generate all dates for the past year
|
||||
const dates = [];
|
||||
const currentDate = new Date(oneYearAgo);
|
||||
while (currentDate <= today) {
|
||||
dates.push(new Date(currentDate));
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const colorLevels = [0, 2, 4, 10, 20];
|
||||
const colors = ['#e2e8f0', '#a5d8ff', '#74c0fc', '#339af0', '#1c7ed6'];
|
||||
|
||||
function getColor(value) {
|
||||
if (value === 0) return colors[0];
|
||||
for (let i = colorLevels.length - 1; i >= 1; i--) {
|
||||
if (value >= colorLevels[i]) return colors[i];
|
||||
}
|
||||
return colors[1];
|
||||
}
|
||||
|
||||
const weeksInYear = Math.ceil(dates.length / 7);
|
||||
const startX = 50;
|
||||
const startY = 20;
|
||||
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
// Generate month labels
|
||||
const monthLabels = [];
|
||||
let currentMonth = oneYearAgo.getMonth();
|
||||
let monthX = startX;
|
||||
|
||||
for (let week = 0; week < weeksInYear; week++) {
|
||||
const weekDate = new Date(oneYearAgo);
|
||||
weekDate.setDate(weekDate.getDate() + week * 7);
|
||||
|
||||
if (weekDate.getMonth() !== currentMonth) {
|
||||
currentMonth = weekDate.getMonth();
|
||||
monthLabels.push({
|
||||
x: monthX,
|
||||
text: months[currentMonth],
|
||||
});
|
||||
monthX = startX + week * (cellSize + cellPadding);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="heatmap-svg"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{/* Render heatmap cells */}
|
||||
{dates.map((date, index) => {
|
||||
const week = Math.floor(index / 7);
|
||||
const day = index % 7;
|
||||
|
||||
const x = startX + week * (cellSize + cellPadding);
|
||||
const y = startY + day * (cellSize + cellPadding);
|
||||
|
||||
const dateKey = date.toISOString().split('T')[0];
|
||||
const value = heatmapData[dateKey] || 0;
|
||||
const color = getColor(value);
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={dateKey}
|
||||
className="heatmap-day"
|
||||
x={x}
|
||||
y={y}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx="2"
|
||||
fill={color}
|
||||
data-date={dateKey}
|
||||
data-count={value}
|
||||
>
|
||||
<title>
|
||||
{dateKey}: {value} activities
|
||||
</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render month labels */}
|
||||
{monthLabels.map((label, index) => (
|
||||
<text key={index} x={label.x} y="15" fontSize="12" fill="#64748b">
|
||||
{label.text}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Render legend */}
|
||||
<text x={startX} y={height - 40} fontSize="12" fill="#64748b">
|
||||
Less
|
||||
</text>
|
||||
{colors.map((color, index) => {
|
||||
const legendX = startX + 40 + index * (cellSize + 2);
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={legendX}
|
||||
y={height - 30}
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<text
|
||||
x={startX + 40 + colors.length * (cellSize + 2) + 5}
|
||||
y={height - 21}
|
||||
fontSize="12"
|
||||
fill="#64748b"
|
||||
>
|
||||
More
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Token Usage Section Component
|
||||
function TokenUsageSection({ tokenUsage }) {
|
||||
const cardClass = 'glass-card p-6';
|
||||
const sectionTitleClass =
|
||||
'text-lg font-semibold tracking-tight text-slate-900';
|
||||
|
||||
function calculateTotalTokens(tokenUsage, type) {
|
||||
return Object.values(tokenUsage).reduce(
|
||||
(acc, usage) => acc + usage[type],
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${cardClass} mt-4 md:mt-6`}>
|
||||
<div className="space-y-3">
|
||||
<h3 className={sectionTitleClass}>Token Usage</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<TokenUsageCard
|
||||
label="Input"
|
||||
value={calculateTotalTokens(tokenUsage, 'input').toLocaleString()}
|
||||
/>
|
||||
<TokenUsageCard
|
||||
label="Output"
|
||||
value={calculateTotalTokens(tokenUsage, 'output').toLocaleString()}
|
||||
/>
|
||||
<TokenUsageCard
|
||||
label="Total"
|
||||
value={calculateTotalTokens(tokenUsage, 'total').toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Token Usage Card Component
|
||||
function TokenUsageCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Achievements Section Component
|
||||
function AchievementsSection({ achievements }) {
|
||||
const cardClass = 'glass-card p-6';
|
||||
const sectionTitleClass =
|
||||
'text-lg font-semibold tracking-tight text-slate-900';
|
||||
|
||||
return (
|
||||
<div className={`${cardClass} mt-4 space-y-4 md:mt-6`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={sectionTitleClass}>Achievements</h3>
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
{achievements.length} total
|
||||
</span>
|
||||
</div>
|
||||
{achievements.length === 0 ? (
|
||||
<p className="text-sm text-slate-600">
|
||||
No achievements yet. Keep coding!
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200">
|
||||
{achievements.map((achievement, index) => (
|
||||
<AchievementItem key={index} achievement={achievement} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Achievement Item Component
|
||||
function AchievementItem({ achievement }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-3 text-left">
|
||||
<span className="text-base font-semibold text-slate-900">
|
||||
{achievement.name}
|
||||
</span>
|
||||
<p className="text-sm text-slate-600">{achievement.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export Button Component
|
||||
function ExportButton() {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
const container = document.getElementById('container');
|
||||
|
||||
if (!container || !window.html2canvas) {
|
||||
alert('Export functionality is not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.href = imgData;
|
||||
link.download = `qwen-insights-${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.click();
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
alert('Failed to export image. Please try again.');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="group inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-3 text-sm font-semibold text-white shadow-soft transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400 hover:-translate-y-[1px] hover:shadow-lg active:translate-y-[1px] disabled:opacity-50"
|
||||
>
|
||||
{isExporting ? 'Exporting...' : 'Export as Image'}
|
||||
<span className="text-slate-200 transition group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// App Initialization - Mount React app when DOM is ready
|
||||
const container = document.getElementById('react-root');
|
||||
if (container && window.INSIGHT_DATA && window.ReactDOM) {
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(React.createElement(InsightApp, { data: window.INSIGHT_DATA }));
|
||||
} else {
|
||||
console.error('Failed to mount React app:', {
|
||||
container: !!container,
|
||||
data: !!window.INSIGHT_DATA,
|
||||
ReactDOM: !!window.ReactDOM,
|
||||
});
|
||||
}
|
||||
610
packages/cli/src/services/insight/templates/styles/base.css
Normal file
610
packages/cli/src/services/insight/templates/styles/base.css
Normal file
@@ -0,0 +1,610 @@
|
||||
/* Tailwind CSS Base Styles extracted from index-CV6J1oXz.css */
|
||||
*,
|
||||
:before,
|
||||
:after,
|
||||
::backdrop {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-gradient-from-position: ;
|
||||
--tw-gradient-via-position: ;
|
||||
--tw-gradient-to-position: ;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: #3b82f680;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
--tw-contain-size: ;
|
||||
--tw-contain-layout: ;
|
||||
--tw-contain-paint: ;
|
||||
--tw-contain-style: ;
|
||||
}
|
||||
|
||||
*,
|
||||
:before,
|
||||
:after {
|
||||
box-sizing: border-box;
|
||||
border: 0 solid #e5e7eb;
|
||||
}
|
||||
|
||||
:before,
|
||||
:after {
|
||||
--tw-content: "";
|
||||
}
|
||||
|
||||
html,
|
||||
:host {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-feature-settings: normal;
|
||||
font-variation-settings: normal;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
|
||||
--tw-gradient-from: #f8fafc var(--tw-gradient-from-position);
|
||||
--tw-gradient-to: #f1f5f9 var(--tw-gradient-to-position);
|
||||
--tw-gradient-stops: var(--tw-gradient-from), #fff var(--tw-gradient-via-position), var(--tw-gradient-to);
|
||||
--tw-text-opacity: 1;
|
||||
min-height: 100vh;
|
||||
color: rgb(15 23 42 / var(--tw-text-opacity, 1));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Glass Card Effect */
|
||||
.glass-card {
|
||||
--tw-border-opacity: 1;
|
||||
border-width: 1px;
|
||||
border-color: rgb(226 232 240 / var(--tw-border-opacity, 1));
|
||||
--tw-shadow: 0 10px 40px #0f172a14;
|
||||
--tw-shadow-colored: 0 10px 40px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
--tw-backdrop-blur: blur(8px);
|
||||
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||
background-color: #ffffff99;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.col-span-2 {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.h-56 {
|
||||
height: 14rem;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.min-w-\[720px\] {
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.max-w-6xl {
|
||||
max-width: 72rem;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.space-y-3> :not([hidden])~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.space-y-4> :not([hidden])~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.divide-y> :not([hidden])~ :not([hidden]) {
|
||||
--tw-divide-y-reverse: 0;
|
||||
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
|
||||
}
|
||||
|
||||
.divide-slate-200> :not([hidden])~ :not([hidden]) {
|
||||
--tw-divide-opacity: 1;
|
||||
border-color: rgb(226 232 240 / var(--tw-divide-opacity, 1));
|
||||
}
|
||||
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-slate-100 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(241 245 249 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-emerald-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(236 253 245 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-slate-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(241 245 249 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-slate-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(248 250 252 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-slate-900 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(15 23 42 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-white\/70 {
|
||||
background-color: #ffffff73;
|
||||
}
|
||||
|
||||
.bg-gradient-to-br {
|
||||
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
|
||||
}
|
||||
|
||||
.from-slate-50 {
|
||||
--tw-gradient-from: #f8fafc var(--tw-gradient-from-position);
|
||||
--tw-gradient-to: #f8fafc00 var(--tw-gradient-to-position);
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
|
||||
}
|
||||
|
||||
.via-white {
|
||||
--tw-gradient-to: #ffffff00 var(--tw-gradient-to-position);
|
||||
--tw-gradient-stops: var(--tw-gradient-from), #ffffff var(--tw-gradient-via-position), var(--tw-gradient-to);
|
||||
}
|
||||
|
||||
.to-slate-100 {
|
||||
--tw-gradient-to: #f1f5f9 var(--tw-gradient-to-position);
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.px-5 {
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.px-8 {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.py-10 {
|
||||
padding-top: 2.5rem;
|
||||
padding-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.py-3 {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.py-6 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tracking-\[0\.2em\] {
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
.tracking-tight {
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.tracking-wide {
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.text-emerald-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(4 120 87 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-rose-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(190 18 60 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-slate-200 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-slate-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-slate-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(100 116 139 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-slate-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(71 85 105 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-slate-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(51 65 85 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-slate-900 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(15 23 42 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-white {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.shadow-inner {
|
||||
--tw-shadow: inset 0 2px 4px 0 #0000000d;
|
||||
--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.shadow-soft {
|
||||
--tw-shadow: 0 10px 40px #0f172a14;
|
||||
--tw-shadow-colored: 0 10px 40px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.shadow-slate-100 {
|
||||
--tw-shadow-color: #f1f5f9;
|
||||
--tw-shadow: var(--tw-shadow-colored);
|
||||
}
|
||||
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
transition-duration: 0.15s;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover\:-translate-y-\[1px\]:hover {
|
||||
--tw-translate-y: -1px;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
.hover\:shadow-lg:hover {
|
||||
--tw-shadow: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;
|
||||
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.focus-visible\:outline:focus-visible {
|
||||
outline-style: solid;
|
||||
}
|
||||
|
||||
.focus-visible\:outline-2:focus-visible {
|
||||
outline-width: 2px;
|
||||
}
|
||||
|
||||
.focus-visible\:outline-offset-2:focus-visible {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.focus-visible\:outline-slate-400:focus-visible {
|
||||
outline-color: #94a3b8;
|
||||
}
|
||||
|
||||
.active\:translate-y-\[1px\]:active {
|
||||
--tw-translate-y: 1px;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:translate-x-0\.5 {
|
||||
--tw-translate-x: 0.125rem;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:mt-6 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.md\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.md\:gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.md\:py-12 {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.md\:text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Heat map specific styles */
|
||||
.heatmap-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-svg {
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.heatmap-day {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-day:hover {
|
||||
stroke: #00000024;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.heatmap-legend-item {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface UsageMetadata {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface HeatMapData {
|
||||
[date: string]: number;
|
||||
}
|
||||
|
||||
export interface TokenUsageData {
|
||||
[date: string]: UsageMetadata;
|
||||
}
|
||||
|
||||
export interface AchievementData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface InsightData {
|
||||
heatmap: HeatMapData;
|
||||
tokenUsage: TokenUsageData;
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
longestWorkDate: string | null;
|
||||
longestWorkDuration: number; // in minutes
|
||||
activeHours: { [hour: number]: number };
|
||||
latestActiveTime: string | null;
|
||||
achievements: AchievementData[];
|
||||
}
|
||||
|
||||
export interface StreakData {
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
dates: string[];
|
||||
}
|
||||
|
||||
export interface StaticInsightTemplateData {
|
||||
styles: string;
|
||||
content: string;
|
||||
data: InsightData;
|
||||
scripts: string;
|
||||
generatedTime: string;
|
||||
}
|
||||
@@ -93,7 +93,6 @@ import {
|
||||
useExtensionUpdates,
|
||||
useConfirmUpdateRequests,
|
||||
useSettingInputRequests,
|
||||
usePluginChoiceRequests,
|
||||
} from './hooks/useExtensionUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
@@ -177,34 +176,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { addSettingInputRequest, settingInputRequests } =
|
||||
useSettingInputRequests();
|
||||
|
||||
const { addPluginChoiceRequest, pluginChoiceRequests } =
|
||||
usePluginChoiceRequests();
|
||||
|
||||
extensionManager.setRequestConsent(
|
||||
requestConsentOrFail.bind(null, (description) =>
|
||||
requestConsentInteractive(description, addConfirmUpdateExtensionRequest),
|
||||
),
|
||||
);
|
||||
|
||||
extensionManager.setRequestChoicePlugin(
|
||||
(marketplace) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
addPluginChoiceRequest({
|
||||
marketplaceName: marketplace.name,
|
||||
plugins: marketplace.plugins.map((p) => ({
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
})),
|
||||
onSelect: (pluginName) => {
|
||||
resolve(pluginName);
|
||||
},
|
||||
onCancel: () => {
|
||||
reject(new Error('Plugin selection cancelled'));
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
extensionManager.setRequestSetting(
|
||||
(setting) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
@@ -1330,7 +1307,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
!!confirmationRequest ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
settingInputRequests.length > 0 ||
|
||||
pluginChoiceRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
@@ -1393,7 +1369,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
settingInputRequests,
|
||||
pluginChoiceRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
@@ -1486,7 +1461,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
settingInputRequests,
|
||||
pluginChoiceRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
|
||||
130
packages/cli/src/ui/commands/insightCommand.ts
Normal file
130
packages/cli/src/ui/commands/insightCommand.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandContext, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { join } from 'path';
|
||||
import os from 'os';
|
||||
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
|
||||
|
||||
// Open file in default browser
|
||||
async function openFileInBrowser(filePath: string): Promise<void> {
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Convert to file:// URL for cross-platform compatibility
|
||||
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
|
||||
|
||||
try {
|
||||
switch (process.platform) {
|
||||
case 'darwin': // macOS
|
||||
await execAsync(`open "${fileUrl}"`);
|
||||
break;
|
||||
case 'win32': // Windows
|
||||
await execAsync(`start "" "${fileUrl}"`);
|
||||
break;
|
||||
default: // Linux and others
|
||||
await execAsync(`xdg-open "${fileUrl}"`);
|
||||
}
|
||||
} catch (_error) {
|
||||
// If opening fails, try with local file path
|
||||
switch (process.platform) {
|
||||
case 'darwin': // macOS
|
||||
await execAsync(`open "${filePath}"`);
|
||||
break;
|
||||
case 'win32': // Windows
|
||||
await execAsync(`start "" "${filePath}"`);
|
||||
break;
|
||||
default: // Linux and others
|
||||
await execAsync(`xdg-open "${filePath}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const insightCommand: SlashCommand = {
|
||||
name: 'insight',
|
||||
get description() {
|
||||
return t(
|
||||
'generate personalized programming insights from your chat history',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
try {
|
||||
context.ui.setDebugMessage(t('Generating insights...'));
|
||||
|
||||
const projectsDir = join(os.homedir(), '.qwen', 'projects');
|
||||
const insightGenerator = new StaticInsightGenerator();
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Processing your chat history...'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Generate the static insight HTML file
|
||||
const outputPath =
|
||||
await insightGenerator.generateStaticInsight(projectsDir);
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Insight report generated successfully!'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Open the file in the default browser
|
||||
try {
|
||||
await openFileInBrowser(outputPath);
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Opening insights in your browser: {{path}}', {
|
||||
path: outputPath,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (browserError) {
|
||||
console.error('Failed to open browser automatically:', browserError);
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Insights generated at: {{path}}. Please open this file in your browser.',
|
||||
{
|
||||
path: outputPath,
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
context.ui.setDebugMessage(t('Insights ready.'));
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to generate insights: {{error}}', {
|
||||
error: (error as Error).message,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
console.error('Insight generation error:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import { FolderTrustDialog } from './FolderTrustDialog.js';
|
||||
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
import { ConsentPrompt } from './ConsentPrompt.js';
|
||||
import { SettingInputPrompt } from './SettingInputPrompt.js';
|
||||
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
@@ -148,19 +147,6 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.pluginChoiceRequests.length > 0) {
|
||||
const request = uiState.pluginChoiceRequests[0];
|
||||
return (
|
||||
<PluginChoicePrompt
|
||||
key={request.marketplaceName}
|
||||
marketplaceName={request.marketplaceName}
|
||||
plugins={request.plugins}
|
||||
onSelect={request.onSelect}
|
||||
onCancel={request.onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isThemeDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseKeypress = vi.mocked(useKeypress);
|
||||
|
||||
describe('PluginChoicePrompt', () => {
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const terminalWidth = 80;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders marketplace name in title', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test-marketplace"
|
||||
plugins={[{ name: 'plugin1' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('test-marketplace');
|
||||
});
|
||||
|
||||
it('renders plugin names', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[
|
||||
{ name: 'plugin1', description: 'First plugin' },
|
||||
{ name: 'plugin2', description: 'Second plugin' },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('plugin1');
|
||||
expect(lastFrame()).toContain('plugin2');
|
||||
});
|
||||
|
||||
it('renders description for selected plugin only', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[
|
||||
{ name: 'plugin1', description: 'First plugin description' },
|
||||
{ name: 'plugin2', description: 'Second plugin description' },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// First plugin is selected by default, should show its description
|
||||
expect(lastFrame()).toContain('First plugin description');
|
||||
});
|
||||
|
||||
it('renders help text', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'plugin1' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('↑↓');
|
||||
expect(lastFrame()).toContain('Enter');
|
||||
expect(lastFrame()).toContain('Escape');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling behavior', () => {
|
||||
it('does not show scroll indicators for small lists', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[
|
||||
{ name: 'plugin1' },
|
||||
{ name: 'plugin2' },
|
||||
{ name: 'plugin3' },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('more above');
|
||||
expect(lastFrame()).not.toContain('more below');
|
||||
});
|
||||
|
||||
it('shows "more below" indicator for long lists', () => {
|
||||
const plugins = Array.from({ length: 15 }, (_, i) => ({
|
||||
name: `plugin${i + 1}`,
|
||||
}));
|
||||
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={plugins}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// At the beginning, should show "more below" but not "more above"
|
||||
expect(lastFrame()).not.toContain('more above');
|
||||
expect(lastFrame()).toContain('more below');
|
||||
});
|
||||
|
||||
it('shows progress indicator for long lists', () => {
|
||||
const plugins = Array.from({ length: 15 }, (_, i) => ({
|
||||
name: `plugin${i + 1}`,
|
||||
}));
|
||||
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={plugins}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show progress like "(1/15)"
|
||||
expect(lastFrame()).toContain('(1/15)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('registers keypress handler', () => {
|
||||
render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'plugin1' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onCancel when escape is pressed', () => {
|
||||
render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'plugin1' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape', sequence: '\x1b' } as never);
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSelect with plugin name when enter is pressed', () => {
|
||||
render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'test-plugin' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'return', sequence: '\r' } as never);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('test-plugin');
|
||||
});
|
||||
|
||||
it('calls onSelect with correct plugin when number key 1-9 is pressed', () => {
|
||||
render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[
|
||||
{ name: 'plugin1' },
|
||||
{ name: 'plugin2' },
|
||||
{ name: 'plugin3' },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: '2', sequence: '2' } as never);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('plugin2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection indicator', () => {
|
||||
it('shows selection indicator for first plugin by default', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'plugin1' }, { name: 'plugin2' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('❯');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
|
||||
interface PluginChoice {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type PluginChoicePromptProps = {
|
||||
marketplaceName: string;
|
||||
plugins: PluginChoice[];
|
||||
onSelect: (pluginName: string) => void;
|
||||
onCancel: () => void;
|
||||
terminalWidth: number;
|
||||
};
|
||||
|
||||
// Maximum number of visible items in the list
|
||||
const MAX_VISIBLE_ITEMS = 8;
|
||||
|
||||
export const PluginChoicePrompt = (props: PluginChoicePromptProps) => {
|
||||
const { marketplaceName, plugins, onSelect, onCancel } = props;
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const prefixWidth = 2; // "❯ " or " "
|
||||
|
||||
const handleKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
const { name, sequence } = key;
|
||||
|
||||
if (name === 'escape') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'return') {
|
||||
const plugin = plugins[selectedIndex];
|
||||
if (plugin) {
|
||||
onSelect(plugin.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate up
|
||||
if (name === 'up' || sequence === 'k') {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : plugins.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate down
|
||||
if (name === 'down' || sequence === 'j') {
|
||||
setSelectedIndex((prev) => (prev < plugins.length - 1 ? prev + 1 : 0));
|
||||
return;
|
||||
}
|
||||
|
||||
// Number shortcuts (1-9)
|
||||
const num = parseInt(sequence || '', 10);
|
||||
if (!isNaN(num) && num >= 1 && num <= plugins.length && num <= 9) {
|
||||
setSelectedIndex(num - 1);
|
||||
const plugin = plugins[num - 1];
|
||||
if (plugin) {
|
||||
onSelect(plugin.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
[plugins, selectedIndex, onSelect, onCancel],
|
||||
);
|
||||
|
||||
useKeypress(handleKeypress, { isActive: true });
|
||||
|
||||
// Calculate visible range for scrolling
|
||||
const { visiblePlugins, startIndex, hasMore, hasLess } = useMemo(() => {
|
||||
const total = plugins.length;
|
||||
if (total <= MAX_VISIBLE_ITEMS) {
|
||||
return {
|
||||
visiblePlugins: plugins,
|
||||
startIndex: 0,
|
||||
hasMore: false,
|
||||
hasLess: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate window position to keep selected item visible
|
||||
let start = 0;
|
||||
const halfWindow = Math.floor(MAX_VISIBLE_ITEMS / 2);
|
||||
|
||||
if (selectedIndex <= halfWindow) {
|
||||
// Near the beginning
|
||||
start = 0;
|
||||
} else if (selectedIndex >= total - halfWindow) {
|
||||
// Near the end
|
||||
start = total - MAX_VISIBLE_ITEMS;
|
||||
} else {
|
||||
// In the middle - center on selected
|
||||
start = selectedIndex - halfWindow;
|
||||
}
|
||||
|
||||
const end = Math.min(start + MAX_VISIBLE_ITEMS, total);
|
||||
|
||||
return {
|
||||
visiblePlugins: plugins.slice(start, end),
|
||||
startIndex: start,
|
||||
hasLess: start > 0,
|
||||
hasMore: end < total,
|
||||
};
|
||||
}, [plugins, selectedIndex]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Select a plugin from "{{name}}"', { name: marketplaceName })}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{/* Show "more items above" indicator */}
|
||||
{hasLess && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
↑ {t('{{count}} more above', { count: String(startIndex) })}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visiblePlugins.map((plugin, visibleIndex) => {
|
||||
const actualIndex = startIndex + visibleIndex;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
const prefix = isSelected ? '❯ ' : ' ';
|
||||
|
||||
return (
|
||||
<Box key={plugin.name} flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text color={isSelected ? theme.text.accent : undefined}>
|
||||
{prefix}
|
||||
</Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={isSelected ? theme.text.accent : undefined}
|
||||
>
|
||||
{plugin.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Show full description only for selected item */}
|
||||
{isSelected && plugin.description && (
|
||||
<Box marginLeft={prefixWidth}>
|
||||
<Text color={theme.text.accent}>{plugin.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show "more items below" indicator */}
|
||||
{hasMore && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
↓{' '}
|
||||
{t('{{count}} more below', {
|
||||
count: String(plugins.length - startIndex - MAX_VISIBLE_ITEMS),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="row" gap={2}>
|
||||
<Text dimColor>
|
||||
{t('Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel')}
|
||||
</Text>
|
||||
{plugins.length > MAX_VISIBLE_ITEMS && (
|
||||
<Text dimColor>
|
||||
({selectedIndex + 1}/{plugins.length})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
SettingInputRequest,
|
||||
PluginChoiceRequest,
|
||||
} from '../types.js';
|
||||
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
@@ -62,7 +61,6 @@ export interface UIState {
|
||||
confirmationRequest: ConfirmationRequest | null;
|
||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||
settingInputRequests: SettingInputRequest[];
|
||||
pluginChoiceRequests: PluginChoiceRequest[];
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
geminiMdFileCount: number;
|
||||
streamingState: StreamingState;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
useExtensionUpdates,
|
||||
useSettingInputRequests,
|
||||
useConfirmUpdateRequests,
|
||||
usePluginChoiceRequests,
|
||||
} from './useExtensionUpdates.js';
|
||||
import {
|
||||
QWEN_DIR,
|
||||
@@ -491,118 +490,3 @@ describe('useExtensionUpdates', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePluginChoiceRequests', () => {
|
||||
it('should add a plugin choice request', () => {
|
||||
const { result } = renderHook(() => usePluginChoiceRequests());
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
act(() => {
|
||||
result.current.addPluginChoiceRequest({
|
||||
marketplaceName: 'test-marketplace',
|
||||
plugins: [
|
||||
{ name: 'plugin1', description: 'First plugin' },
|
||||
{ name: 'plugin2', description: 'Second plugin' },
|
||||
],
|
||||
onSelect,
|
||||
onCancel,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.pluginChoiceRequests).toHaveLength(1);
|
||||
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
|
||||
'test-marketplace',
|
||||
);
|
||||
expect(result.current.pluginChoiceRequests[0].plugins).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove a plugin choice request when a plugin is selected', () => {
|
||||
const { result } = renderHook(() => usePluginChoiceRequests());
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
act(() => {
|
||||
result.current.addPluginChoiceRequest({
|
||||
marketplaceName: 'test-marketplace',
|
||||
plugins: [{ name: 'plugin1' }],
|
||||
onSelect,
|
||||
onCancel,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.pluginChoiceRequests).toHaveLength(1);
|
||||
|
||||
// Select a plugin
|
||||
act(() => {
|
||||
result.current.pluginChoiceRequests[0].onSelect('plugin1');
|
||||
});
|
||||
|
||||
expect(result.current.pluginChoiceRequests).toHaveLength(0);
|
||||
expect(onSelect).toHaveBeenCalledWith('plugin1');
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a plugin choice request when cancelled', () => {
|
||||
const { result } = renderHook(() => usePluginChoiceRequests());
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
act(() => {
|
||||
result.current.addPluginChoiceRequest({
|
||||
marketplaceName: 'test-marketplace',
|
||||
plugins: [{ name: 'plugin1' }],
|
||||
onSelect,
|
||||
onCancel,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.pluginChoiceRequests).toHaveLength(1);
|
||||
|
||||
// Cancel the request
|
||||
act(() => {
|
||||
result.current.pluginChoiceRequests[0].onCancel();
|
||||
});
|
||||
|
||||
expect(result.current.pluginChoiceRequests).toHaveLength(0);
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple plugin choice requests', () => {
|
||||
const { result } = renderHook(() => usePluginChoiceRequests());
|
||||
|
||||
const onSelect1 = vi.fn();
|
||||
const onCancel1 = vi.fn();
|
||||
const onSelect2 = vi.fn();
|
||||
const onCancel2 = vi.fn();
|
||||
|
||||
act(() => {
|
||||
result.current.addPluginChoiceRequest({
|
||||
marketplaceName: 'marketplace-1',
|
||||
plugins: [{ name: 'plugin1' }],
|
||||
onSelect: onSelect1,
|
||||
onCancel: onCancel1,
|
||||
});
|
||||
result.current.addPluginChoiceRequest({
|
||||
marketplaceName: 'marketplace-2',
|
||||
plugins: [{ name: 'plugin2' }],
|
||||
onSelect: onSelect2,
|
||||
onCancel: onCancel2,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.pluginChoiceRequests).toHaveLength(2);
|
||||
|
||||
// Select from first request
|
||||
act(() => {
|
||||
result.current.pluginChoiceRequests[0].onSelect('plugin1');
|
||||
});
|
||||
|
||||
expect(result.current.pluginChoiceRequests).toHaveLength(1);
|
||||
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
|
||||
'marketplace-2',
|
||||
);
|
||||
expect(onSelect1).toHaveBeenCalledWith('plugin1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
MessageType,
|
||||
type ConfirmationRequest,
|
||||
type SettingInputRequest,
|
||||
type PluginChoiceRequest,
|
||||
} from '../types.js';
|
||||
import { checkExhaustive } from '../../utils/checks.js';
|
||||
|
||||
@@ -145,71 +144,6 @@ export const useSettingInputRequests = () => {
|
||||
};
|
||||
};
|
||||
|
||||
type PluginChoiceRequestWrapper = {
|
||||
marketplaceName: string;
|
||||
plugins: Array<{ name: string; description?: string }>;
|
||||
onSelect: (pluginName: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
type PluginChoiceRequestAction =
|
||||
| { type: 'add'; request: PluginChoiceRequestWrapper }
|
||||
| { type: 'remove'; request: PluginChoiceRequestWrapper };
|
||||
|
||||
function pluginChoiceRequestsReducer(
|
||||
state: PluginChoiceRequestWrapper[],
|
||||
action: PluginChoiceRequestAction,
|
||||
): PluginChoiceRequestWrapper[] {
|
||||
switch (action.type) {
|
||||
case 'add':
|
||||
return [...state, action.request];
|
||||
case 'remove':
|
||||
return state.filter((r) => r !== action.request);
|
||||
default:
|
||||
checkExhaustive(action);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export const usePluginChoiceRequests = () => {
|
||||
const [pluginChoiceRequests, dispatchPluginChoiceRequests] = useReducer(
|
||||
pluginChoiceRequestsReducer,
|
||||
[],
|
||||
);
|
||||
const addPluginChoiceRequest = useCallback(
|
||||
(original: PluginChoiceRequest) => {
|
||||
const wrappedRequest: PluginChoiceRequestWrapper = {
|
||||
marketplaceName: original.marketplaceName,
|
||||
plugins: original.plugins,
|
||||
onSelect: (pluginName: string) => {
|
||||
dispatchPluginChoiceRequests({
|
||||
type: 'remove',
|
||||
request: wrappedRequest,
|
||||
});
|
||||
original.onSelect(pluginName);
|
||||
},
|
||||
onCancel: () => {
|
||||
dispatchPluginChoiceRequests({
|
||||
type: 'remove',
|
||||
request: wrappedRequest,
|
||||
});
|
||||
original.onCancel();
|
||||
},
|
||||
};
|
||||
dispatchPluginChoiceRequests({
|
||||
type: 'add',
|
||||
request: wrappedRequest,
|
||||
});
|
||||
},
|
||||
[dispatchPluginChoiceRequests],
|
||||
);
|
||||
return {
|
||||
addPluginChoiceRequest,
|
||||
pluginChoiceRequests,
|
||||
dispatchPluginChoiceRequests,
|
||||
};
|
||||
};
|
||||
|
||||
export const useExtensionUpdates = (
|
||||
extensionManager: ExtensionManager,
|
||||
addItem: UseHistoryManagerReturn['addItem'],
|
||||
|
||||
@@ -422,15 +422,3 @@ export interface SettingInputRequest {
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export interface PluginChoice {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PluginChoiceRequest {
|
||||
marketplaceName: string;
|
||||
plugins: PluginChoice[];
|
||||
onSelect: (pluginName: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@ import {
|
||||
type ModelProvidersConfig,
|
||||
type AvailableModel,
|
||||
} from '../models/index.js';
|
||||
import type { ClaudeMarketplaceConfig } from '../extension/claude-converter.js';
|
||||
|
||||
// Re-export types
|
||||
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
|
||||
@@ -211,8 +210,10 @@ export interface ExtensionInstallMetadata {
|
||||
ref?: string;
|
||||
autoUpdate?: boolean;
|
||||
allowPreRelease?: boolean;
|
||||
marketplaceConfig?: ClaudeMarketplaceConfig;
|
||||
pluginName?: string;
|
||||
marketplace?: {
|
||||
marketplaceSource: string;
|
||||
pluginName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000;
|
||||
|
||||
@@ -28,6 +28,7 @@ type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent;
|
||||
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
import { AnthropicContentConverter } from './converter.js';
|
||||
import { buildRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js';
|
||||
|
||||
type StreamingBlockState = {
|
||||
type: string;
|
||||
@@ -54,6 +55,9 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
) {
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
const baseURL = contentGeneratorConfig.baseUrl;
|
||||
// Configure runtime options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let Anthropic SDK timeout control the request
|
||||
const runtimeOptions = buildRuntimeFetchOptions('anthropic');
|
||||
|
||||
this.client = new Anthropic({
|
||||
apiKey: contentGeneratorConfig.apiKey,
|
||||
@@ -61,6 +65,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
timeout: contentGeneratorConfig.timeout,
|
||||
maxRetries: contentGeneratorConfig.maxRetries,
|
||||
defaultHeaders,
|
||||
...runtimeOptions,
|
||||
});
|
||||
|
||||
this.converter = new AnthropicContentConverter(
|
||||
|
||||
@@ -19,6 +19,8 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { AuthType } from '../../contentGenerator.js';
|
||||
import type { ChatCompletionToolWithCache } from './types.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
// Mock OpenAI
|
||||
vi.mock('openai', () => ({
|
||||
@@ -32,6 +34,10 @@ vi.mock('openai', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
|
||||
buildRuntimeFetchOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
let provider: DashScopeOpenAICompatibleProvider;
|
||||
let mockContentGeneratorConfig: ContentGeneratorConfig;
|
||||
@@ -39,6 +45,11 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const mockedBuildRuntimeFetchOptions =
|
||||
buildRuntimeFetchOptions as unknown as MockedFunction<
|
||||
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
|
||||
>;
|
||||
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
|
||||
|
||||
// Mock ContentGeneratorConfig
|
||||
mockContentGeneratorConfig = {
|
||||
@@ -185,18 +196,20 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
it('should create OpenAI client with DashScope configuration', () => {
|
||||
const client = provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-CacheControl': 'enable',
|
||||
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
|
||||
},
|
||||
});
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-CacheControl': 'enable',
|
||||
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
@@ -207,13 +220,15 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
|
||||
provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: expect.any(Object),
|
||||
});
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ChatCompletionContentPartWithCache,
|
||||
ChatCompletionToolWithCache,
|
||||
} from './types.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
export class DashScopeOpenAICompatibleProvider
|
||||
implements OpenAICompatibleProvider
|
||||
@@ -68,12 +69,16 @@ export class DashScopeOpenAICompatibleProvider
|
||||
maxRetries = DEFAULT_MAX_RETRIES,
|
||||
} = this.contentGeneratorConfig;
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
// Configure fetch options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
|
||||
const fetchOptions = buildRuntimeFetchOptions('openai');
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
timeout,
|
||||
maxRetries,
|
||||
defaultHeaders,
|
||||
...(fetchOptions ? { fetchOptions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { DefaultOpenAICompatibleProvider } from './default.js';
|
||||
import type { Config } from '../../../config/config.js';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
// Mock OpenAI
|
||||
vi.mock('openai', () => ({
|
||||
@@ -30,6 +32,10 @@ vi.mock('openai', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
|
||||
buildRuntimeFetchOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DefaultOpenAICompatibleProvider', () => {
|
||||
let provider: DefaultOpenAICompatibleProvider;
|
||||
let mockContentGeneratorConfig: ContentGeneratorConfig;
|
||||
@@ -37,6 +43,11 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const mockedBuildRuntimeFetchOptions =
|
||||
buildRuntimeFetchOptions as unknown as MockedFunction<
|
||||
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
|
||||
>;
|
||||
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
|
||||
|
||||
// Mock ContentGeneratorConfig
|
||||
mockContentGeneratorConfig = {
|
||||
@@ -112,15 +123,17 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
it('should create OpenAI client with correct configuration', () => {
|
||||
const client = provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
});
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
@@ -131,15 +144,17 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
|
||||
provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
});
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include custom headers from buildHeaders', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Config } from '../../../config/config.js';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import type { OpenAICompatibleProvider } from './types.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
/**
|
||||
* Default provider for standard OpenAI-compatible APIs
|
||||
@@ -43,12 +44,16 @@ export class DefaultOpenAICompatibleProvider
|
||||
maxRetries = DEFAULT_MAX_RETRIES,
|
||||
} = this.contentGeneratorConfig;
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
// Configure fetch options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
|
||||
const fetchOptions = buildRuntimeFetchOptions('openai');
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
timeout,
|
||||
maxRetries,
|
||||
defaultHeaders,
|
||||
...(fetchOptions ? { fetchOptions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -290,8 +290,8 @@ export function convertClaudeToQwenConfig(
|
||||
claudeConfig: ClaudePluginConfig,
|
||||
): ExtensionConfig {
|
||||
// Validate required fields
|
||||
if (!claudeConfig.name) {
|
||||
throw new Error('Claude plugin config must have name field');
|
||||
if (!claudeConfig.name || !claudeConfig.version) {
|
||||
throw new Error('Claude plugin config must have name and version fields');
|
||||
}
|
||||
|
||||
// Parse MCP servers
|
||||
@@ -386,7 +386,7 @@ export async function convertClaudePluginPackage(
|
||||
}
|
||||
|
||||
// Step 3: Load and merge plugin.json if exists (based on strict mode)
|
||||
const strict = marketplacePlugin.strict ?? false;
|
||||
const strict = marketplacePlugin.strict ?? true;
|
||||
let mergedConfig: ClaudePluginConfig;
|
||||
|
||||
if (strict) {
|
||||
@@ -583,7 +583,7 @@ export function mergeClaudeConfigs(
|
||||
marketplacePlugin: ClaudeMarketplacePluginConfig,
|
||||
pluginConfig?: ClaudePluginConfig,
|
||||
): ClaudePluginConfig {
|
||||
if (!pluginConfig && marketplacePlugin.strict === true) {
|
||||
if (!pluginConfig && marketplacePlugin.strict !== false) {
|
||||
throw new Error(
|
||||
`Plugin ${marketplacePlugin.name} requires plugin.json (strict mode)`,
|
||||
);
|
||||
@@ -709,12 +709,6 @@ async function resolvePluginSource(
|
||||
throw new Error(`Plugin source not found at ${sourcePath}`);
|
||||
}
|
||||
|
||||
// If source path equals marketplace dir (source is '.' or ''),
|
||||
// return marketplaceDir directly to avoid copying to subdirectory of self
|
||||
if (path.resolve(sourcePath) === path.resolve(marketplaceDir)) {
|
||||
return marketplaceDir;
|
||||
}
|
||||
|
||||
// Copy to plugin directory
|
||||
await fs.promises.cp(sourcePath, pluginDir, { recursive: true });
|
||||
return pluginDir;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
validateName,
|
||||
getExtensionId,
|
||||
hashValue,
|
||||
parseInstallSource,
|
||||
type ExtensionConfig,
|
||||
} from './extensionManager.js';
|
||||
import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js';
|
||||
@@ -779,5 +780,46 @@ describe('extension tests', () => {
|
||||
expect(id).toBe(hashValue('https://github.com/owner/repo'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInstallSource', () => {
|
||||
it('should parse HTTPS URL as git type', async () => {
|
||||
const result = await parseInstallSource(
|
||||
'https://github.com/owner/repo',
|
||||
);
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.source).toBe('https://github.com/owner/repo');
|
||||
});
|
||||
|
||||
it('should parse HTTP URL as git type', async () => {
|
||||
const result = await parseInstallSource('http://example.com/repo');
|
||||
expect(result.type).toBe('git');
|
||||
});
|
||||
|
||||
it('should parse git@ URL as git type', async () => {
|
||||
const result = await parseInstallSource(
|
||||
'git@github.com:owner/repo.git',
|
||||
);
|
||||
expect(result.type).toBe('git');
|
||||
});
|
||||
|
||||
it('should parse sso:// URL as git type', async () => {
|
||||
const result = await parseInstallSource('sso://some/path');
|
||||
expect(result.type).toBe('git');
|
||||
});
|
||||
|
||||
it('should parse marketplace URL correctly', async () => {
|
||||
const result = await parseInstallSource(
|
||||
'https://example.com/marketplace:plugin-name',
|
||||
);
|
||||
expect(result.type).toBe('marketplace');
|
||||
expect(result.marketplace?.pluginName).toBe('plugin-name');
|
||||
});
|
||||
|
||||
it('should throw for non-existent local path', async () => {
|
||||
await expect(
|
||||
parseInstallSource('/nonexistent/path/to/extension'),
|
||||
).rejects.toThrow('Install source not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
ExtensionInstallMetadata,
|
||||
SkillConfig,
|
||||
SubagentConfig,
|
||||
ClaudeMarketplaceConfig,
|
||||
} from '../index.js';
|
||||
import {
|
||||
Storage,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
@@ -38,11 +36,11 @@ import {
|
||||
} from './github.js';
|
||||
import type { LoadExtensionContext } from './variableSchema.js';
|
||||
import { Override, type AllExtensionsEnablementConfig } from './override.js';
|
||||
import { parseMarketplaceSource } from './marketplace.js';
|
||||
import {
|
||||
isGeminiExtensionConfig,
|
||||
convertGeminiExtensionPackage,
|
||||
} from './gemini-converter.js';
|
||||
import { convertClaudePluginPackage } from './claude-converter.js';
|
||||
import { glob } from 'glob';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
@@ -64,7 +62,9 @@ import {
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionUpdateEvent,
|
||||
} from '../telemetry/types.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { loadSkillsFromDir } from '../skills/skill-load.js';
|
||||
import { convertClaudePluginPackage } from './claude-converter.js';
|
||||
import { loadSubagentFromDir } from '../subagents/subagent-manager.js';
|
||||
|
||||
// ============================================================================
|
||||
@@ -151,9 +151,6 @@ export interface ExtensionManagerOptions {
|
||||
config?: Config;
|
||||
requestConsent?: (options?: ExtensionRequestOptions) => Promise<void>;
|
||||
requestSetting?: (setting: ExtensionSetting) => Promise<string>;
|
||||
requestChoicePlugin?: (
|
||||
marketplace: ClaudeMarketplaceConfig,
|
||||
) => Promise<string>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -277,9 +274,6 @@ export class ExtensionManager {
|
||||
private isWorkspaceTrusted: boolean;
|
||||
private requestConsent: (options?: ExtensionRequestOptions) => Promise<void>;
|
||||
private requestSetting?: (setting: ExtensionSetting) => Promise<string>;
|
||||
private requestChoicePlugin: (
|
||||
marketplace: ClaudeMarketplaceConfig,
|
||||
) => Promise<string>;
|
||||
|
||||
constructor(options: ExtensionManagerOptions) {
|
||||
this.workspaceDir = options.workspaceDir ?? process.cwd();
|
||||
@@ -292,8 +286,6 @@ export class ExtensionManager {
|
||||
'extension-enablement.json',
|
||||
);
|
||||
this.requestSetting = options.requestSetting;
|
||||
this.requestChoicePlugin =
|
||||
options.requestChoicePlugin || (() => Promise.resolve(''));
|
||||
this.requestConsent = options.requestConsent || (() => Promise.resolve());
|
||||
this.config = options.config;
|
||||
this.telemetrySettings = options.telemetrySettings;
|
||||
@@ -316,14 +308,6 @@ export class ExtensionManager {
|
||||
this.requestSetting = requestSetting;
|
||||
}
|
||||
|
||||
setRequestChoicePlugin(
|
||||
requestChoicePlugin: (
|
||||
marketplace: ClaudeMarketplaceConfig,
|
||||
) => Promise<string>,
|
||||
): void {
|
||||
this.requestChoicePlugin = requestChoicePlugin;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Enablement functionality (directly implemented)
|
||||
// ==========================================================================
|
||||
@@ -688,9 +672,9 @@ export class ExtensionManager {
|
||||
pathSeparator: path.sep,
|
||||
}) as unknown as ExtensionConfig;
|
||||
|
||||
if (!config.name) {
|
||||
if (!config.name || !config.version) {
|
||||
throw new Error(
|
||||
`Invalid configuration in ${configFilePath}: missing "name"}`,
|
||||
`Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`,
|
||||
);
|
||||
}
|
||||
validateName(config.name);
|
||||
@@ -750,20 +734,35 @@ export class ExtensionManager {
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
let claudePluginName: string | undefined;
|
||||
|
||||
if (
|
||||
installMetadata.type === 'marketplace' &&
|
||||
installMetadata.marketplaceConfig &&
|
||||
!installMetadata.pluginName
|
||||
) {
|
||||
const pluginName = await this.requestChoicePlugin(
|
||||
installMetadata.marketplaceConfig,
|
||||
// Handle marketplace installation
|
||||
if (installMetadata.type === 'marketplace') {
|
||||
const marketplaceParsed = parseMarketplaceSource(
|
||||
installMetadata.source,
|
||||
);
|
||||
installMetadata.pluginName = pluginName;
|
||||
}
|
||||
if (!marketplaceParsed) {
|
||||
throw new Error(
|
||||
`Invalid marketplace source format: ${installMetadata.source}. Expected format: marketplace-url:plugin-name`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
installMetadata.type === 'marketplace' ||
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
await downloadFromGitHubRelease(
|
||||
{
|
||||
source: marketplaceParsed.marketplaceSource,
|
||||
type: 'git',
|
||||
},
|
||||
tempDir,
|
||||
);
|
||||
} catch (_error) {
|
||||
await cloneFromGit(installMetadata, tempDir);
|
||||
installMetadata.type = 'git';
|
||||
}
|
||||
localSourcePath = tempDir;
|
||||
claudePluginName = marketplaceParsed.pluginName;
|
||||
} else if (
|
||||
installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release'
|
||||
) {
|
||||
@@ -773,21 +772,11 @@ export class ExtensionManager {
|
||||
installMetadata,
|
||||
tempDir,
|
||||
);
|
||||
if (
|
||||
installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release'
|
||||
) {
|
||||
installMetadata.type = result.type;
|
||||
installMetadata.releaseTag = result.tagName;
|
||||
}
|
||||
installMetadata.type = result.type;
|
||||
installMetadata.releaseTag = result.tagName;
|
||||
} catch (_error) {
|
||||
await cloneFromGit(installMetadata, tempDir);
|
||||
if (
|
||||
installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release'
|
||||
) {
|
||||
installMetadata.type = 'git';
|
||||
}
|
||||
installMetadata.type = 'git';
|
||||
}
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
@@ -802,7 +791,7 @@ export class ExtensionManager {
|
||||
try {
|
||||
localSourcePath = await convertGeminiOrClaudeExtension(
|
||||
localSourcePath,
|
||||
installMetadata.pluginName,
|
||||
claudePluginName,
|
||||
);
|
||||
newExtensionConfig = this.loadExtensionConfig({
|
||||
extensionDir: localSourcePath,
|
||||
@@ -908,7 +897,12 @@ export class ExtensionManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (installMetadata.type !== 'link') {
|
||||
if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release' ||
|
||||
installMetadata.type === 'marketplace'
|
||||
) {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
@@ -1256,3 +1250,38 @@ export function validateName(name: string) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseInstallSource(
|
||||
source: string,
|
||||
): Promise<ExtensionInstallMetadata> {
|
||||
let installMetadata: ExtensionInstallMetadata;
|
||||
const marketplaceParsed = parseMarketplaceSource(source);
|
||||
if (marketplaceParsed) {
|
||||
installMetadata = {
|
||||
source,
|
||||
type: 'marketplace',
|
||||
marketplace: marketplaceParsed,
|
||||
};
|
||||
} else if (
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
) {
|
||||
installMetadata = {
|
||||
source,
|
||||
type: 'git',
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
await stat(source);
|
||||
installMetadata = {
|
||||
source,
|
||||
type: 'local',
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Install source not found.');
|
||||
}
|
||||
}
|
||||
return installMetadata;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,32 @@ describe('git extension helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use marketplace source for marketplace type extensions', async () => {
|
||||
const installMetadata = {
|
||||
source: 'marketplace:my-plugin',
|
||||
type: 'marketplace' as const,
|
||||
marketplace: {
|
||||
pluginName: 'my-plugin',
|
||||
marketplaceSource: 'https://github.com/marketplace/my-plugin',
|
||||
},
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{
|
||||
name: 'origin',
|
||||
refs: { fetch: 'https://github.com/marketplace/my-plugin' },
|
||||
},
|
||||
]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'https://github.com/marketplace/my-plugin',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should use source for marketplace type without marketplace metadata', async () => {
|
||||
const installMetadata = {
|
||||
source: 'http://fallback-repo.com',
|
||||
|
||||
@@ -53,7 +53,10 @@ export async function cloneFromGit(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const git = simpleGit(destination);
|
||||
let sourceUrl = installMetadata.source;
|
||||
let sourceUrl =
|
||||
installMetadata.type === 'marketplace' && installMetadata.marketplace
|
||||
? installMetadata.marketplace.marketplaceSource
|
||||
: installMetadata.source;
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
try {
|
||||
@@ -236,8 +239,12 @@ export async function downloadFromGitHubRelease(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
): Promise<GitHubDownloadResult> {
|
||||
const { source, ref } = installMetadata;
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source);
|
||||
const { source, ref, marketplace, type } = installMetadata;
|
||||
const { owner, repo } = parseGitHubRepoForReleases(
|
||||
type === 'marketplace' && marketplace
|
||||
? marketplace.marketplaceSource
|
||||
: source,
|
||||
);
|
||||
|
||||
try {
|
||||
const releaseData = await fetchReleaseFromGithub(owner, repo, ref);
|
||||
|
||||
@@ -2,5 +2,3 @@ export * from './extensionManager.js';
|
||||
export * from './variables.js';
|
||||
export * from './github.js';
|
||||
export * from './extensionSettings.js';
|
||||
export * from './marketplace.js';
|
||||
export * from './claude-converter.js';
|
||||
|
||||
@@ -4,208 +4,75 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { parseInstallSource } from './marketplace.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as https from 'node:https';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMarketplaceSource } from './marketplace.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:https', () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./github.js', () => ({
|
||||
parseGitHubRepoForReleases: vi.fn((url: string) => {
|
||||
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (match) {
|
||||
return { owner: match[1], repo: match[2] };
|
||||
}
|
||||
throw new Error('Not a GitHub URL');
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('parseInstallSource', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: HTTPS requests fail (no marketplace config)
|
||||
vi.mocked(https.get).mockImplementation((_url, _options, callback) => {
|
||||
const mockRes = {
|
||||
statusCode: 404,
|
||||
on: vi.fn(),
|
||||
};
|
||||
if (typeof callback === 'function') {
|
||||
callback(mockRes as never);
|
||||
}
|
||||
return { on: vi.fn() } as never;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('owner/repo format parsing', () => {
|
||||
it('should parse owner/repo format without plugin name', async () => {
|
||||
const result = await parseInstallSource('owner/repo');
|
||||
|
||||
expect(result.source).toBe('https://github.com/owner/repo');
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.pluginName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse owner/repo format with plugin name', async () => {
|
||||
const result = await parseInstallSource('owner/repo:my-plugin');
|
||||
|
||||
expect(result.source).toBe('https://github.com/owner/repo');
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.pluginName).toBe('my-plugin');
|
||||
});
|
||||
|
||||
it('should handle owner/repo with dashes and underscores', async () => {
|
||||
const result = await parseInstallSource('my-org/my_repo:plugin-name');
|
||||
|
||||
expect(result.source).toBe('https://github.com/my-org/my_repo');
|
||||
expect(result.pluginName).toBe('plugin-name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTPS URL parsing', () => {
|
||||
it('should parse HTTPS GitHub URL without plugin name', async () => {
|
||||
const result = await parseInstallSource('https://github.com/owner/repo');
|
||||
|
||||
expect(result.source).toBe('https://github.com/owner/repo');
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.pluginName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse HTTPS GitHub URL with plugin name', async () => {
|
||||
const result = await parseInstallSource(
|
||||
'https://github.com/owner/repo:my-plugin',
|
||||
describe('Marketplace Installation', () => {
|
||||
describe('parseMarketplaceSource', () => {
|
||||
it('should parse valid marketplace source with http URL', () => {
|
||||
const result = parseMarketplaceSource(
|
||||
'http://example.com/marketplace:my-plugin',
|
||||
);
|
||||
|
||||
expect(result.source).toBe('https://github.com/owner/repo');
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.pluginName).toBe('my-plugin');
|
||||
});
|
||||
|
||||
it('should not treat port number as plugin name', async () => {
|
||||
const result = await parseInstallSource('https://example.com:8080/repo');
|
||||
|
||||
expect(result.source).toBe('https://example.com:8080/repo');
|
||||
expect(result.pluginName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('git@ URL parsing', () => {
|
||||
it('should parse git@ URL without plugin name', async () => {
|
||||
const result = await parseInstallSource('git@github.com:owner/repo.git');
|
||||
|
||||
expect(result.source).toBe('git@github.com:owner/repo.git');
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.pluginName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse git@ URL with plugin name', async () => {
|
||||
const result = await parseInstallSource(
|
||||
'git@github.com:owner/repo.git:my-plugin',
|
||||
);
|
||||
|
||||
expect(result.source).toBe('git@github.com:owner/repo.git');
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.pluginName).toBe('my-plugin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('local path parsing', () => {
|
||||
it('should parse local path without plugin name', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValueOnce({} as never);
|
||||
|
||||
const result = await parseInstallSource('/path/to/extension');
|
||||
|
||||
expect(result.source).toBe('/path/to/extension');
|
||||
expect(result.type).toBe('local');
|
||||
expect(result.pluginName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse local path with plugin name', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValueOnce({} as never);
|
||||
|
||||
const result = await parseInstallSource('/path/to/extension:my-plugin');
|
||||
|
||||
expect(result.source).toBe('/path/to/extension');
|
||||
expect(result.type).toBe('local');
|
||||
expect(result.pluginName).toBe('my-plugin');
|
||||
});
|
||||
|
||||
it('should throw error for non-existent local path', async () => {
|
||||
vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
await expect(parseInstallSource('/nonexistent/path')).rejects.toThrow(
|
||||
'Install source not found: /nonexistent/path',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Windows drive letter correctly', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValueOnce({} as never);
|
||||
|
||||
const result = await parseInstallSource('C:\\path\\to\\extension');
|
||||
|
||||
expect(result.source).toBe('C:\\path\\to\\extension');
|
||||
expect(result.type).toBe('local');
|
||||
// The colon after C should not be treated as plugin separator
|
||||
expect(result.pluginName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('marketplace config detection', () => {
|
||||
it('should detect marketplace type when config exists', async () => {
|
||||
const mockMarketplaceConfig = {
|
||||
name: 'test-marketplace',
|
||||
owner: { name: 'Test Owner' },
|
||||
plugins: [{ name: 'plugin1' }],
|
||||
};
|
||||
|
||||
// Mock successful API response
|
||||
vi.mocked(https.get).mockImplementation((_url, _options, callback) => {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(JSON.stringify(mockMarketplaceConfig)));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
if (typeof callback === 'function') {
|
||||
callback(mockRes as never);
|
||||
}
|
||||
return { on: vi.fn() } as never;
|
||||
expect(result).toEqual({
|
||||
marketplaceSource: 'http://example.com/marketplace',
|
||||
pluginName: 'my-plugin',
|
||||
});
|
||||
|
||||
const result = await parseInstallSource('owner/repo');
|
||||
|
||||
expect(result.type).toBe('marketplace');
|
||||
expect(result.marketplaceConfig).toEqual(mockMarketplaceConfig);
|
||||
});
|
||||
|
||||
it('should remain git type when marketplace config not found', async () => {
|
||||
// HTTPS returns 404 (default mock behavior)
|
||||
const result = await parseInstallSource('owner/repo');
|
||||
it('should parse valid marketplace source with https URL', () => {
|
||||
const result = parseMarketplaceSource(
|
||||
'https://github.com/example/marketplace:awesome-plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceSource: 'https://github.com/example/marketplace',
|
||||
pluginName: 'awesome-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.marketplaceConfig).toBeUndefined();
|
||||
it('should handle plugin names with hyphens', () => {
|
||||
const result = parseMarketplaceSource(
|
||||
'https://example.com:my-super-plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceSource: 'https://example.com',
|
||||
pluginName: 'my-super-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle URLs with ports', () => {
|
||||
const result = parseMarketplaceSource(
|
||||
'https://example.com:8080/marketplace:plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceSource: 'https://example.com:8080/marketplace',
|
||||
pluginName: 'plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for source without colon separator', () => {
|
||||
const result = parseMarketplaceSource('https://example.com/plugin');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for source without URL', () => {
|
||||
const result = parseMarketplaceSource('not-a-url:plugin');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for source with empty plugin name', () => {
|
||||
const result = parseMarketplaceSource('https://example.com:');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should use last colon as separator', () => {
|
||||
// URLs with ports have colons, should use the last one
|
||||
const result = parseMarketplaceSource(
|
||||
'https://example.com:8080:my-plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceSource: 'https://example.com:8080',
|
||||
pluginName: 'my-plugin',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* This module handles installation of extensions from Claude marketplaces.
|
||||
*
|
||||
* A marketplace URL format: marketplace-url:plugin-name
|
||||
* Example: https://github.com/example/marketplace:my-plugin
|
||||
*/
|
||||
|
||||
import type { ExtensionConfig } from './extensionManager.js';
|
||||
import type { ExtensionInstallMetadata } from '../config/config.js';
|
||||
import type { ClaudeMarketplaceConfig } from './claude-converter.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as https from 'node:https';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { parseGitHubRepoForReleases } from './github.js';
|
||||
|
||||
export interface MarketplaceInstallOptions {
|
||||
marketplaceUrl: string;
|
||||
@@ -27,242 +28,34 @@ export interface MarketplaceInstallResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the install source string into repo and optional pluginName.
|
||||
* Format: <repo>:<pluginName> where pluginName is optional
|
||||
* The colon separator is only treated as a pluginName delimiter when:
|
||||
* - It's not part of a URL scheme (http://, https://, git@, sso://)
|
||||
* - It appears after the repo portion
|
||||
* Parse marketplace install source string.
|
||||
* Format: marketplace-url:plugin-name
|
||||
*/
|
||||
function parseSourceAndPluginName(source: string): {
|
||||
repo: string;
|
||||
pluginName?: string;
|
||||
} {
|
||||
// Check if source contains a colon that could be a pluginName separator
|
||||
// We need to handle URL schemes that contain colons
|
||||
const urlSchemes = ['http://', 'https://', 'git@', 'sso://'];
|
||||
|
||||
let repoEndIndex = source.length;
|
||||
let hasPluginName = false;
|
||||
|
||||
// For URLs, find the last colon after the scheme
|
||||
for (const scheme of urlSchemes) {
|
||||
if (source.startsWith(scheme)) {
|
||||
const afterScheme = source.substring(scheme.length);
|
||||
const lastColonIndex = afterScheme.lastIndexOf(':');
|
||||
if (lastColonIndex !== -1) {
|
||||
// Check if what follows the colon looks like a pluginName (not a port number or path)
|
||||
const potentialPluginName = afterScheme.substring(lastColonIndex + 1);
|
||||
// Plugin name should not contain '/' and should not be a number (port)
|
||||
if (
|
||||
potentialPluginName &&
|
||||
!potentialPluginName.includes('/') &&
|
||||
!/^\d+/.test(potentialPluginName)
|
||||
) {
|
||||
repoEndIndex = scheme.length + lastColonIndex;
|
||||
hasPluginName = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
export function parseMarketplaceSource(source: string): {
|
||||
marketplaceSource: string;
|
||||
pluginName: string;
|
||||
} | null {
|
||||
// Check if source contains a colon separator
|
||||
const lastColonIndex = source.lastIndexOf(':');
|
||||
if (lastColonIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For non-URL sources (local paths or owner/repo format)
|
||||
// Split at the last colon to separate URL from plugin name
|
||||
const marketplaceSource = source.substring(0, lastColonIndex);
|
||||
const pluginName = source.substring(lastColonIndex + 1);
|
||||
|
||||
// Validate that marketplace URL looks like a URL
|
||||
if (
|
||||
repoEndIndex === source.length &&
|
||||
!urlSchemes.some((s) => source.startsWith(s))
|
||||
!marketplaceSource.startsWith('http://') &&
|
||||
!marketplaceSource.startsWith('https://')
|
||||
) {
|
||||
const lastColonIndex = source.lastIndexOf(':');
|
||||
// On Windows, avoid treating drive letter as pluginName separator (e.g., C:\path)
|
||||
if (lastColonIndex > 1) {
|
||||
repoEndIndex = lastColonIndex;
|
||||
hasPluginName = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPluginName) {
|
||||
return {
|
||||
repo: source.substring(0, repoEndIndex),
|
||||
pluginName: source.substring(repoEndIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
return { repo: source };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string matches the owner/repo format (e.g., "anthropics/skills")
|
||||
*/
|
||||
function isOwnerRepoFormat(source: string): boolean {
|
||||
// owner/repo format: word/word, no slashes before, no protocol
|
||||
const ownerRepoRegex = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
|
||||
return ownerRepoRegex.test(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert owner/repo format to GitHub HTTPS URL
|
||||
*/
|
||||
function convertOwnerRepoToGitHubUrl(ownerRepo: string): string {
|
||||
return `https://github.com/${ownerRepo}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if source is a git URL
|
||||
*/
|
||||
function isGitUrl(source: string): boolean {
|
||||
return (
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch content from a URL
|
||||
*/
|
||||
function fetchUrl(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
https
|
||||
.get(url, { headers }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
resolve(Buffer.concat(chunks).toString());
|
||||
});
|
||||
})
|
||||
.on('error', () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch marketplace config from GitHub repository.
|
||||
* Primary: GitHub API (supports private repos with token)
|
||||
* Fallback: raw.githubusercontent.com (no rate limit for public repos)
|
||||
*/
|
||||
async function fetchGitHubMarketplaceConfig(
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<ClaudeMarketplaceConfig | null> {
|
||||
const token = process.env['GITHUB_TOKEN'];
|
||||
|
||||
// Primary: GitHub API (works for private repos, but has rate limits)
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/.claude-plugin/marketplace.json`;
|
||||
const apiHeaders: Record<string, string> = {
|
||||
'User-Agent': 'qwen-code',
|
||||
Accept: 'application/vnd.github.v3.raw',
|
||||
};
|
||||
if (token) {
|
||||
apiHeaders['Authorization'] = `token ${token}`;
|
||||
}
|
||||
|
||||
let content = await fetchUrl(apiUrl, apiHeaders);
|
||||
|
||||
// Fallback: raw.githubusercontent.com (no rate limit, public repos only)
|
||||
if (!content) {
|
||||
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
|
||||
const rawHeaders: Record<string, string> = {
|
||||
'User-Agent': 'qwen-code',
|
||||
};
|
||||
content = await fetchUrl(rawUrl, rawHeaders);
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(content) as ClaudeMarketplaceConfig;
|
||||
} catch {
|
||||
if (!pluginName || pluginName.length === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read marketplace config from local path
|
||||
*/
|
||||
async function readLocalMarketplaceConfig(
|
||||
localPath: string,
|
||||
): Promise<ClaudeMarketplaceConfig | null> {
|
||||
const marketplaceConfigPath = path.join(
|
||||
localPath,
|
||||
'.claude-plugin',
|
||||
'marketplace.json',
|
||||
);
|
||||
try {
|
||||
const content = await fs.promises.readFile(marketplaceConfigPath, 'utf-8');
|
||||
return JSON.parse(content) as ClaudeMarketplaceConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseInstallSource(
|
||||
source: string,
|
||||
): Promise<ExtensionInstallMetadata> {
|
||||
// Step 1: Parse source into repo and optional pluginName
|
||||
const { repo, pluginName } = parseSourceAndPluginName(source);
|
||||
|
||||
let installMetadata: ExtensionInstallMetadata;
|
||||
let repoSource = repo;
|
||||
let marketplaceConfig: ClaudeMarketplaceConfig | null = null;
|
||||
|
||||
// Step 2: Determine repo type and convert owner/repo format if needed
|
||||
if (isGitUrl(repo)) {
|
||||
// Git URL (http://, https://, git@, sso://)
|
||||
installMetadata = {
|
||||
source: repoSource,
|
||||
type: 'git',
|
||||
pluginName,
|
||||
};
|
||||
|
||||
// Try to fetch marketplace config from GitHub
|
||||
try {
|
||||
const { owner, repo: repoName } = parseGitHubRepoForReleases(repoSource);
|
||||
marketplaceConfig = await fetchGitHubMarketplaceConfig(owner, repoName);
|
||||
} catch {
|
||||
// Not a valid GitHub URL or failed to fetch, continue without marketplace config
|
||||
}
|
||||
} else if (isOwnerRepoFormat(repo)) {
|
||||
// owner/repo format - convert to GitHub URL
|
||||
repoSource = convertOwnerRepoToGitHubUrl(repo);
|
||||
installMetadata = {
|
||||
source: repoSource,
|
||||
type: 'git',
|
||||
pluginName,
|
||||
};
|
||||
|
||||
// Try to fetch marketplace config from GitHub
|
||||
const [owner, repoName] = repo.split('/');
|
||||
marketplaceConfig = await fetchGitHubMarketplaceConfig(owner, repoName);
|
||||
} else {
|
||||
// Local path
|
||||
try {
|
||||
await stat(repo);
|
||||
installMetadata = {
|
||||
source: repo,
|
||||
type: 'local',
|
||||
pluginName,
|
||||
};
|
||||
|
||||
// Try to read marketplace config from local path
|
||||
marketplaceConfig = await readLocalMarketplaceConfig(repo);
|
||||
} catch {
|
||||
throw new Error(`Install source not found: ${repo}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: If marketplace config exists, update type to marketplace
|
||||
if (marketplaceConfig) {
|
||||
installMetadata.type = 'marketplace';
|
||||
installMetadata.marketplaceConfig = marketplaceConfig;
|
||||
}
|
||||
|
||||
return installMetadata;
|
||||
|
||||
return { marketplaceSource, pluginName };
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export * from './utils/promptIdContext.js';
|
||||
export * from './utils/thoughtUtils.js';
|
||||
export * from './utils/toml-to-markdown-converter.js';
|
||||
export * from './utils/yaml-parser.js';
|
||||
export * from './utils/jsonl-utils.js';
|
||||
|
||||
// Config resolution utilities
|
||||
export * from './utils/configResolver.js';
|
||||
|
||||
167
packages/core/src/utils/runtimeFetchOptions.ts
Normal file
167
packages/core/src/utils/runtimeFetchOptions.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { EnvHttpProxyAgent } from 'undici';
|
||||
|
||||
/**
|
||||
* JavaScript runtime type
|
||||
*/
|
||||
export type Runtime = 'node' | 'bun' | 'unknown';
|
||||
|
||||
/**
|
||||
* Detect the current JavaScript runtime
|
||||
*/
|
||||
export function detectRuntime(): Runtime {
|
||||
if (typeof process !== 'undefined' && process.versions?.['bun']) {
|
||||
return 'bun';
|
||||
}
|
||||
if (typeof process !== 'undefined' && process.versions?.node) {
|
||||
return 'node';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime fetch options for OpenAI SDK
|
||||
*/
|
||||
export type OpenAIRuntimeFetchOptions =
|
||||
| {
|
||||
dispatcher?: EnvHttpProxyAgent;
|
||||
timeout?: false;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Runtime fetch options for Anthropic SDK
|
||||
*/
|
||||
export type AnthropicRuntimeFetchOptions = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
httpAgent?: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fetch?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* SDK type identifier
|
||||
*/
|
||||
export type SDKType = 'openai' | 'anthropic';
|
||||
|
||||
/**
|
||||
* Build runtime-specific fetch options for OpenAI SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: 'openai',
|
||||
): OpenAIRuntimeFetchOptions;
|
||||
/**
|
||||
* Build runtime-specific fetch options for Anthropic SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: 'anthropic',
|
||||
): AnthropicRuntimeFetchOptions;
|
||||
/**
|
||||
* Build runtime-specific fetch options based on the detected runtime and SDK type
|
||||
* This function applies runtime-specific configurations to handle timeout differences
|
||||
* across Node.js and Bun, ensuring user-configured timeout works as expected.
|
||||
*
|
||||
* @param sdkType - The SDK type ('openai' or 'anthropic') to determine return type
|
||||
* @returns Runtime-specific options compatible with the specified SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: SDKType,
|
||||
): OpenAIRuntimeFetchOptions | AnthropicRuntimeFetchOptions {
|
||||
const runtime = detectRuntime();
|
||||
|
||||
// Always disable bodyTimeout (set to 0) to let SDK's timeout parameter
|
||||
// control the total request time. bodyTimeout only monitors intervals between
|
||||
// data chunks, not the total request time, so we disable it to ensure user-configured
|
||||
// timeout works as expected for both streaming and non-streaming requests.
|
||||
|
||||
switch (runtime) {
|
||||
case 'bun': {
|
||||
if (sdkType === 'openai') {
|
||||
// Bun: Disable built-in 300s timeout to let OpenAI SDK timeout control
|
||||
// This ensures user-configured timeout works as expected without interference
|
||||
return {
|
||||
timeout: false,
|
||||
};
|
||||
} else {
|
||||
// Bun: Use custom fetch to disable built-in 300s timeout
|
||||
// This allows Anthropic SDK timeout to control the request
|
||||
// Note: Bun's fetch automatically uses proxy settings from environment variables
|
||||
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), so proxy behavior is preserved
|
||||
const bunFetch: typeof fetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
const bunFetchOptions: RequestInit = {
|
||||
...init,
|
||||
// @ts-expect-error - Bun-specific timeout option
|
||||
timeout: false,
|
||||
};
|
||||
return fetch(input, bunFetchOptions);
|
||||
};
|
||||
return {
|
||||
fetch: bunFetch,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case 'node': {
|
||||
// Node.js: Use EnvHttpProxyAgent to configure proxy and disable bodyTimeout
|
||||
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
|
||||
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) to preserve proxy functionality
|
||||
// bodyTimeout is always 0 (disabled) to let SDK timeout control the request
|
||||
try {
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
bodyTimeout: 0, // Disable to let SDK timeout control total request time
|
||||
});
|
||||
|
||||
if (sdkType === 'openai') {
|
||||
return {
|
||||
dispatcher: agent,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
httpAgent: agent,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If undici is not available, return appropriate default
|
||||
if (sdkType === 'openai') {
|
||||
return undefined;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown runtime: Try to use EnvHttpProxyAgent if available
|
||||
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
|
||||
try {
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
bodyTimeout: 0, // Disable to let SDK timeout control total request time
|
||||
});
|
||||
|
||||
if (sdkType === 'openai') {
|
||||
return {
|
||||
dispatcher: agent,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
httpAgent: agent,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
if (sdkType === 'openai') {
|
||||
return undefined;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,10 @@ function copyFilesRecursive(source, target, rootSourceDir) {
|
||||
const normalizedPath = relativePath.replace(/\\/g, '/');
|
||||
const isLocaleJs =
|
||||
ext === '.js' && normalizedPath.startsWith('i18n/locales/');
|
||||
if (extensionsToCopy.includes(ext) || isLocaleJs) {
|
||||
const isInsightTemplate = normalizedPath.startsWith(
|
||||
'services/insight/templates/',
|
||||
);
|
||||
if (extensionsToCopy.includes(ext) || isLocaleJs || isInsightTemplate) {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user