Compare commits

..

13 Commits

Author SHA1 Message Date
DragonnZhang
20e38502fe feat(insight): integrate JSONL file reading utility and enhance base CSS styles 2026-01-23 20:44:32 +08:00
DragonnZhang
1c997bdfff refactor(insight): improve error handling and format output path message 2026-01-23 20:09:57 +08:00
DragonnZhang
635ed2ce96 feat(insight): update insight template and app to React, enhance export functionality 2026-01-23 20:06:06 +08:00
DragonnZhang
0c229ec9b5 refactor(insight): remove debug logging and unused test generator 2026-01-23 18:02:09 +08:00
DragonnZhang
5d369c1d99 refactor(insight): remove deprecated insight server implementation 2026-01-23 17:42:08 +08:00
DragonnZhang
e281b19782 chore: update ESLint configuration and lint-staged command 2026-01-23 17:39:24 +08:00
DragonnZhang
3f227b819d feat(insight): Implement static insight generation and visualization
- Add HTML template for insights display.
- Create JavaScript application logic for rendering insights.
- Introduce CSS styles for layout and design.
- Develop a test generator for validating the static insight generator.
- Define TypeScript interfaces for structured insight data.
- Refactor insight command to generate insights and open in browser.
- Remove the need for a server process by generating static files directly.
2026-01-23 17:30:52 +08:00
DragonnZhang
483cc583ce refactor(insight): update insight page assets and styles 2026-01-23 17:30:52 +08:00
DragonnZhang
c738b3a2fb feat: add new insight page with Vite setup 2026-01-23 17:30:52 +08:00
DragonnZhang
359ef6dbca feat(insight): add insight command and server for personalized programming insights 2026-01-23 17:30:52 +08:00
Mingholy
829ba9c431 Merge pull request #1516 from QwenLM/mingholy/fix/runtime-timeout
feat: add runtime-aware fetch options for Anthropic and OpenAI providers
2026-01-23 14:27:50 +08:00
mingholy.lmh
4a0e55530b test: mock runtime fetch options in DashScope and Default OpenAI providers 2026-01-19 11:37:10 +08:00
mingholy.lmh
510d38fe3a feat: add runtime-aware fetch options for Anthropic and OpenAI providers 2026-01-16 17:18:48 +08:00
26 changed files with 2174 additions and 479 deletions

View File

@@ -33,19 +33,16 @@ concurrency:
cancel-in-progress: false
jobs:
# First job: Determine version and run tests once
prepare:
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'
outputs:
release_version: '${{ steps.version.outputs.RELEASE_VERSION }}'
release_tag: '${{ steps.version.outputs.RELEASE_TAG }}'
vscode_tag: '${{ steps.version.outputs.VSCODE_TAG }}'
is_preview: '${{ steps.vars.outputs.is_preview }}'
is_dry_run: '${{ steps.vars.outputs.is_dry_run }}'
issues: 'write'
steps:
- name: 'Checkout'
@@ -85,6 +82,11 @@ jobs:
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'
@@ -119,6 +121,15 @@ jobs:
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' }}
@@ -130,210 +141,67 @@ jobs:
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
# Second job: Build platform-specific VSIXes in parallel
build:
needs: 'prepare'
strategy:
fail-fast: false
matrix:
include:
# Platform-specific builds (with node-pty native binaries)
- os: 'ubuntu-latest'
target: 'linux-x64'
universal: false
# macOS 15 (x64): use macos-15-intel
# Endpoint Badge: macos-latest-large, macos-15-large, or macos-15-intel
- os: 'macos-15-intel'
target: 'darwin-x64'
universal: false
# macOS 15 Arm64: use macos-latest
# Endpoint Badge: macos-latest, macos-15, or macos-15-xlarge
- os: 'macos-latest'
target: 'darwin-arm64'
universal: false
- os: 'windows-latest'
target: 'win32-x64'
universal: false
# Universal fallback (without node-pty, uses child_process)
- os: 'ubuntu-latest'
target: ''
universal: true
runs-on: '${{ matrix.os }}'
permissions:
contents: 'read'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- 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'
run: |-
npm install -g @vscode/vsce
- name: 'Update package version'
env:
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
shell: 'bash'
run: |-
npm run release:version -- "${RELEASE_VERSION}"
- name: 'Prepare VSCode Extension'
env:
UNIVERSAL_BUILD: '${{ matrix.universal }}'
VSCODE_TARGET: '${{ matrix.target }}'
run: |
# Build and stage the extension + bundled CLI
# Build and stage the extension + bundled CLI once.
npm --workspace=qwen-code-vscode-ide-companion run prepackage
- name: 'Package VSIX (platform-specific)'
if: '${{ matrix.target != '''' }}'
- name: 'Package VSIX (dry run)'
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
working-directory: 'packages/vscode-ide-companion'
run: |-
if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then
vsce package --no-dependencies --pre-release --target ${{ matrix.target }} \
--out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.vsix
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 --no-dependencies --target ${{ matrix.target }} \
--out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.vsix
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
fi
shell: 'bash'
- name: 'Package VSIX (universal)'
if: '${{ matrix.target == '''' }}'
working-directory: 'packages/vscode-ide-companion'
run: |-
if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then
vsce package --no-dependencies --pre-release \
--out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-universal.vsix
else
vsce package --no-dependencies \
--out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-universal.vsix
fi
shell: 'bash'
- name: 'Upload VSIX Artifact'
- name: 'Upload VSIX Artifact (dry run)'
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
uses: 'actions/upload-artifact@v4'
with:
name: 'vsix-${{ matrix.target || ''universal'' }}'
path: 'qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-*.vsix'
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'
# Third job: Publish all VSIXes to marketplaces
publish:
needs:
- 'prepare'
- 'build'
runs-on: 'ubuntu-latest'
environment:
name: 'production-release'
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ needs.prepare.outputs.release_tag }}'
permissions:
contents: 'read'
issues: 'write'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
- name: 'Download all VSIX artifacts'
uses: 'actions/download-artifact@v4'
with:
pattern: 'vsix-*'
path: 'vsix-artifacts'
merge-multiple: true
- name: 'List downloaded artifacts'
run: |-
echo "Downloaded VSIX files:"
ls -la vsix-artifacts/
- name: 'Install VSCE and OVSX'
run: |-
npm install -g @vscode/vsce
npm install -g ovsx
- name: 'Publish to Microsoft Marketplace'
if: '${{ needs.prepare.outputs.is_dry_run == ''false'' && needs.prepare.outputs.is_preview != ''true'' }}'
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: |-
echo "Publishing to Microsoft Marketplace..."
for vsix in vsix-artifacts/*.vsix; do
echo "Publishing: ${vsix}"
vsce publish --packagePath "${vsix}" --pat "${VSCE_PAT}"
done
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: '${{ needs.prepare.outputs.is_dry_run == ''false'' }}'
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: |-
echo "Publishing to OpenVSX..."
for vsix in vsix-artifacts/*.vsix; do
echo "Publishing: ${vsix}"
if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then
ovsx publish "${vsix}" --pat "${OVSX_TOKEN}" --pre-release
else
ovsx publish "${vsix}" --pat "${OVSX_TOKEN}"
fi
done
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: 'Upload all VSIXes as release artifacts (dry run)'
if: '${{ needs.prepare.outputs.is_dry_run == ''true'' }}'
uses: 'actions/upload-artifact@v4'
with:
name: 'all-vsix-packages-${{ needs.prepare.outputs.release_version }}'
path: 'vsix-artifacts/*.vsix'
if-no-files-found: 'error'
report-failure:
name: 'Create Issue on Failure'
needs:
- 'prepare'
- 'build'
- 'publish'
if: |-
${{
always() &&
(
needs.build.result == 'failure' ||
needs.build.result == 'cancelled' ||
needs.publish.result == 'failure' ||
needs.publish.result == 'cancelled'
)
}}
runs-on: 'ubuntu-latest'
permissions:
contents: 'read'
issues: 'write'
steps:
- name: 'Create failure issue'
- name: 'Create Issue on Failure'
if: |-
${{ failure() }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
GH_REPO: '${{ github.repository }}'
run: |-
gh issue create \
--repo "${GH_REPO}" \
--title "VSCode IDE Companion Release Failed for ${RELEASE_VERSION} on $(date +'%Y-%m-%d')" \
--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}"

24
package-lock.json generated
View File

@@ -63,7 +63,8 @@
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0"
"@lydell/node-pty-win32-x64": "1.1.0",
"node-pty": "^1.0.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
@@ -11924,6 +11925,13 @@
"thenify-all": "^1.0.0"
}
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT",
"optional": true
},
"node_modules/nano-spawn": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz",
@@ -12071,6 +12079,17 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"nan": "^2.17.0"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -18032,7 +18051,8 @@
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0"
"@lydell/node-pty-win32-x64": "1.1.0",
"node-pty": "^1.0.0"
}
},
"packages/core/node_modules/@google/genai": {

View File

@@ -119,7 +119,8 @@
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0"
"@lydell/node-pty-win32-x64": "1.1.0",
"node-pty": "^1.0.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [

View File

@@ -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);

View 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,
};
}
}

View File

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

View File

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

View File

@@ -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>

View File

@@ -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,
});
}

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

View File

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

View 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);
}
},
};

View File

@@ -77,7 +77,8 @@
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0"
"@lydell/node-pty-win32-x64": "1.1.0",
"node-pty": "^1.0.0"
},
"devDependencies": {
"@qwen-code/qwen-code-test-utils": "file:../test-utils",

View File

@@ -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(

View File

@@ -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),
}),
);
});
});

View File

@@ -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 } : {}),
});
}

View File

@@ -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', () => {

View File

@@ -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 } : {}),
});
}

View File

@@ -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';

View 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 {};
}
}
}
}
}

View File

@@ -20,7 +20,6 @@
],
"scripts": {
"build": "node scripts/build.js",
"bundle:cli": "node scripts/bundle-cli.js",
"test": "vitest run",
"test:ci": "vitest run",
"test:watch": "vitest",
@@ -29,8 +28,8 @@
"lint:fix": "eslint src test --fix",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"prepublishOnly": "npm run clean && npm run build && npm run bundle:cli",
"prepack": "npm run build && npm run bundle:cli"
"prepublishOnly": "npm run clean && npm run build",
"prepack": "npm run build"
},
"keywords": [
"qwen",

View File

@@ -91,3 +91,35 @@ if (existsSync(licenseSource)) {
console.warn('Could not copy LICENSE:', error.message);
}
}
console.log('Bundling CLI into SDK package...');
const repoRoot = join(rootDir, '..', '..');
const rootDistDir = join(repoRoot, 'dist');
if (!existsSync(rootDistDir) || !existsSync(join(rootDistDir, 'cli.js'))) {
console.log('Building CLI bundle...');
try {
execSync('npm run bundle', { stdio: 'inherit', cwd: repoRoot });
} catch (error) {
console.error('Failed to build CLI bundle:', error.message);
throw error;
}
}
const cliDistDir = join(rootDir, 'dist', 'cli');
mkdirSync(cliDistDir, { recursive: true });
console.log('Copying CLI bundle...');
cpSync(join(rootDistDir, 'cli.js'), join(cliDistDir, 'cli.js'));
const vendorSource = join(rootDistDir, 'vendor');
if (existsSync(vendorSource)) {
cpSync(vendorSource, join(cliDistDir, 'vendor'), { recursive: true });
}
const localesSource = join(rootDistDir, 'locales');
if (existsSync(localesSource)) {
cpSync(localesSource, join(cliDistDir, 'locales'), { recursive: true });
}
console.log('CLI bundle copied successfully to SDK package');

View File

@@ -1,83 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Bundles/copies the Qwen Code CLI into the SDK package dist/ so consumers
* don't need a separate CLI install.
*
* This is intentionally NOT part of the SDK "build" step; it is a packaging
* concern (run via npm lifecycle hooks like prepack/prepublishOnly).
*/
import { spawnSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const sdkRoot = join(__dirname, '..');
const repoRoot = join(sdkRoot, '..', '..');
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
...opts,
});
if (res.error) throw res.error;
if (typeof res.status === 'number' && res.status !== 0) {
throw new Error(
`Command failed (${res.status}): ${cmd} ${args.map((a) => JSON.stringify(a)).join(' ')}`,
);
}
}
function ensureRootBundle() {
const rootDistDir = join(repoRoot, 'dist');
const rootCliJs = join(rootDistDir, 'cli.js');
if (existsSync(rootCliJs)) return;
console.log(
'[sdk prepack] Root CLI bundle missing; running `npm run bundle`',
);
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
run(npm, ['run', 'bundle'], { cwd: repoRoot });
}
function main() {
ensureRootBundle();
const rootDistDir = join(repoRoot, 'dist');
const rootCliJs = join(rootDistDir, 'cli.js');
const cliDistDir = join(sdkRoot, 'dist', 'cli');
if (!existsSync(join(sdkRoot, 'dist'))) {
throw new Error(
'[sdk prepack] SDK dist/ not found. Run `npm run build` in packages/sdk-typescript first.',
);
}
rmSync(cliDistDir, { recursive: true, force: true });
mkdirSync(cliDistDir, { recursive: true });
console.log('[sdk prepack] Copying CLI bundle into SDK dist/...');
cpSync(rootCliJs, join(cliDistDir, 'cli.js'));
const vendorSource = join(rootDistDir, 'vendor');
if (existsSync(vendorSource)) {
cpSync(vendorSource, join(cliDistDir, 'vendor'), { recursive: true });
}
const localesSource = join(rootDistDir, 'locales');
if (existsSync(localesSource)) {
cpSync(localesSource, join(cliDistDir, 'locales'), { recursive: true });
}
console.log('[sdk prepack] CLI bundle copied successfully');
}
main();

View File

@@ -20,7 +20,6 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -49,140 +48,9 @@ function run(cmd, args, opts = {}) {
}
}
function parseVsceTarget(target) {
if (!target) return null;
const parts = target.split('-');
if (parts.length !== 2) return null;
const [platform, arch] = parts;
return { platform, arch };
}
function getExpectedRipgrepDirName() {
const target = parseVsceTarget(process.env.VSCODE_TARGET);
const platform = target?.platform ?? process.platform;
const arch = target?.arch ?? process.arch;
const normalizedPlatform =
platform === 'darwin' || platform === 'linux' || platform === 'win32'
? platform
: null;
const normalizedArch = arch === 'x64' || arch === 'arm64' ? arch : null;
if (!normalizedPlatform || !normalizedArch) return null;
return `${normalizedArch}-${normalizedPlatform}`;
}
function pruneBundledRipgrep() {
const isUniversalBuild = process.env.UNIVERSAL_BUILD === 'true';
if (isUniversalBuild) {
console.log('[prepackage] Universal build: keeping all ripgrep binaries');
return;
}
if (!process.env.VSCODE_TARGET) {
console.log(
'[prepackage] VSCODE_TARGET not set: keeping all ripgrep binaries',
);
return;
}
const expectedDirName = getExpectedRipgrepDirName();
if (!expectedDirName) {
console.warn(
'[prepackage] Could not resolve expected ripgrep target; keeping all binaries',
);
return;
}
const ripgrepDir = path.join(bundledCliDir, 'vendor', 'ripgrep');
if (!fs.existsSync(ripgrepDir)) {
console.log('[prepackage] No bundled ripgrep directory found; skipping');
return;
}
const entries = fs.readdirSync(ripgrepDir, { withFileTypes: true });
const removed = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const name = entry.name;
if (!/^(x64|arm64)-(darwin|linux|win32)$/.test(name)) continue;
if (name === expectedDirName) continue;
const fullPath = path.join(ripgrepDir, name);
fs.rmSync(fullPath, { recursive: true, force: true });
removed.push(name);
}
if (removed.length === 0) {
console.log(
`[prepackage] Ripgrep already pruned for ${expectedDirName} (no changes)`,
);
return;
}
console.log(
`[prepackage] Pruned ripgrep binaries; kept ${expectedDirName}, removed: ${removed.join(', ')}`,
);
}
function removeSelfReferenceFromNodeModules() {
if (process.platform !== 'win32') return;
const packageJsonPath = path.join(bundledCliDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) return;
let packageName;
try {
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageName = parsed?.name;
} catch {
return;
}
if (typeof packageName !== 'string' || packageName.length === 0) return;
// Some npm installations on Windows can create a junction in node_modules
// pointing back to the package itself. vsce/yazl can't zip that reliably.
let selfPath;
if (packageName.startsWith('@')) {
const [scope, name] = packageName.split('/');
if (!scope || !name) return;
selfPath = path.join(bundledCliDir, 'node_modules', scope, name);
} else {
selfPath = path.join(bundledCliDir, 'node_modules', packageName);
}
if (!fs.existsSync(selfPath)) return;
fs.rmSync(selfPath, { recursive: true, force: true });
console.log(
`[prepackage] Windows: removed self-reference from node_modules: ${packageName}`,
);
// Cleanup empty scope directory (cosmetic).
try {
const parentDir = path.dirname(selfPath);
if (
fs.existsSync(parentDir) &&
fs.statSync(parentDir).isDirectory() &&
fs.readdirSync(parentDir).length === 0
) {
fs.rmdirSync(parentDir);
}
} catch {
// Best-effort cleanup only.
}
}
function main() {
const npm = npmBin();
// Root bundling depends on built workspace outputs. Use the root build to
// ensure all required workspace dist/ artifacts exist.
console.log('[prepackage] Building repo...');
run(npm, ['--prefix', repoRoot, 'run', 'build'], { cwd: repoRoot });
console.log('[prepackage] Bundling root CLI...');
run(npm, ['--prefix', repoRoot, 'run', 'bundle'], { cwd: repoRoot });
@@ -192,6 +60,12 @@ function main() {
console.log('[prepackage] Generating notices...');
run(npm, ['run', 'generate:notices'], { cwd: extensionRoot });
console.log('[prepackage] Typechecking...');
run(npm, ['run', 'check-types'], { cwd: extensionRoot });
console.log('[prepackage] Linting...');
run(npm, ['run', 'lint'], { cwd: extensionRoot });
console.log('[prepackage] Building extension (production)...');
run(npm, ['run', 'build:prod'], { cwd: extensionRoot });
@@ -204,42 +78,21 @@ function main() {
},
);
const isUniversalBuild = process.env.UNIVERSAL_BUILD === 'true';
console.log(
'[prepackage] Installing production deps into extension dist/qwen-cli...',
);
const installArgs = [
'--prefix',
bundledCliDir,
'install',
'--omit=dev',
'--no-audit',
'--no-fund',
];
// For universal build, exclude optional dependencies (node-pty native binaries)
// This ensures the universal VSIX works on all platforms using child_process fallback
if (isUniversalBuild) {
installArgs.push('--omit=optional');
console.log(
'[prepackage] Universal build: excluding optional dependencies (node-pty)',
);
}
run(npm, installArgs, {
cwd: bundledCliDir,
env: {
...process.env,
npm_config_workspaces: 'false',
npm_config_include_workspace_root: 'false',
npm_config_link_workspace_packages: 'false',
},
});
removeSelfReferenceFromNodeModules();
pruneBundledRipgrep();
run(
npm,
[
'--prefix',
bundledCliDir,
'install',
'--omit=dev',
'--no-audit',
'--no-fund',
],
{ cwd: bundledCliDir },
);
}
main();

View File

@@ -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);
}
}

View File

@@ -161,6 +161,7 @@ const distPackageJson = {
'@lydell/node-pty-linux-x64': '1.1.0',
'@lydell/node-pty-win32-arm64': '1.1.0',
'@lydell/node-pty-win32-x64': '1.1.0',
'node-pty': '^1.0.0',
},
engines: rootPackageJson.engines,
};