Compare commits

..

18 Commits

Author SHA1 Message Date
DragonnZhang
7475ffcbeb refactor(insight): update insight page assets and styles 2026-01-16 14:57:21 +08:00
DragonnZhang
ccd51a6a00 feat: add new insight page with Vite setup 2026-01-16 14:49:40 +08:00
DragonnZhang
a7e14255c3 feat(insight): add insight command and server for personalized programming insights 2026-01-16 13:38:31 +08:00
tanzhenxin
886f914fb3 Merge pull request #1496 from QwenLM/fix/vscode-run
fix(vscode-ide-companion): simplify ELECTRON_RUN_AS_NODE detection and improve README
2026-01-15 09:00:11 +08:00
tanzhenxin
90365af2f8 Merge pull request #1499 from QwenLM/fix/1498
fix: include --acp flag in tool exclusion check
2026-01-15 08:56:58 +08:00
yiliang114
cbef5ffd89 fix: include --acp flag in tool exclusion check
Fixed #1498

The tool exclusion logic only checked --experimental-acp but not --acp,
causing edit, write_file, and run_shell_command to be incorrectly
excluded when VS Code extension uses --acp flag in ACP mode.
2026-01-14 22:49:04 +08:00
yiliang114
5e80e80387 fix(vscode-ide-companion): simplify ELECTRON_RUN_AS_NODE detection and improve README
- Bump version to 0.7.1
- Simplify macOS/Linux terminal launch by always using ELECTRON_RUN_AS_NODE=1
  (all VSCode-like IDEs are Electron-based)
- Update README with marketplace badges, cleaner docs structure
- Fix broken markdown table row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:10:19 +08:00
Mingholy
985f65f8fa Merge pull request #1494 from QwenLM/chore/v0.7.1
chore: bump version to 0.7.1
2026-01-14 18:29:59 +08:00
Mingholy
9b9c5fadd5 Merge pull request #1492 from QwenLM/mingholy/fix/loggingContentGenerator-timing-issue
Fix timing issue in LoggingContentGenerator initialization
2026-01-14 18:09:26 +08:00
Mingholy
372c67cad4 Merge pull request #1489 from QwenLM/fix/slow-quit
Reduce slow quit by trimming skills watchers
2026-01-14 18:07:37 +08:00
mingholy.lmh
af3864b5de chore: bump version to 0.7.1
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-14 18:02:43 +08:00
mingholy.lmh
1e3791f30a fix: ci issue 2026-01-14 17:51:00 +08:00
mingholy.lmh
9bf626d051 refactor: streamline initialization of LoggingContentGenerator and update auth type retrieval 2026-01-14 16:44:51 +08:00
mingholy.lmh
a35af6550f fix: timing issue of initialize loggingContentGenerator 2026-01-14 16:17:35 +08:00
tanzhenxin
d6607e134e update 2026-01-14 15:40:53 +08:00
tanzhenxin
9024a41723 Conditional skill manager initialization with improved file watching
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-14 15:22:49 +08:00
yiliang114
bde056b62e Merge branch 'main' of https://github.com/QwenLM/qwen-code into fix/vscode-run 2026-01-14 13:11:58 +08:00
yiliang114
97497457a8 Merge branch 'main' of https://github.com/QwenLM/qwen-code into fix/vscode-run 2026-01-13 14:21:26 +08:00
41 changed files with 34661 additions and 1301 deletions

View File

@@ -26,6 +26,7 @@ export default tseslint.config(
'dist/**',
'docs-site/.next/**',
'docs-site/out/**',
'packages/cli/src/services/insight-page/**',
],
},
eslint.configs.recommended,

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.0",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.7.0",
"version": "0.7.1",
"workspaces": [
"packages/*"
],
@@ -17310,7 +17310,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.7.0",
"version": "0.7.1",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17947,7 +17947,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.7.0",
"version": "0.7.1",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -21408,7 +21408,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.0",
"version": "0.7.1",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21420,7 +21420,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.7.0",
"version": "0.7.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.0",
"version": "0.7.1",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -125,7 +125,7 @@
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix --max-warnings 0"
"eslint --fix --max-warnings 0 --no-warn-ignored"
],
"*.{json,md}": [
"prettier --write"

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.0",
"version": "0.7.1",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
},
"dependencies": {
"@google/genai": "1.30.0",

View File

@@ -874,11 +874,10 @@ export async function loadCliConfig(
}
};
if (
!interactive &&
!argv.experimentalAcp &&
inputFormat !== InputFormat.STREAM_JSON
) {
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
const isAcpMode = argv.acp || argv.experimentalAcp;
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
switch (approvalMode) {
case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT:

View File

@@ -18,7 +18,6 @@ import { copyCommand } from '../ui/commands/copyCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { exportCommand } from '../ui/commands/exportCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
@@ -40,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
@@ -68,7 +68,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
docsCommand,
directoryCommand,
editorCommand,
exportCommand,
extensionsCommand,
helpCommand,
await ideCommand(),
@@ -90,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,120 @@
# Qwen Code Insights Page
A React-based visualization dashboard for displaying coding activity insights and statistics.
## Development
This application consists of two parts:
1. **Backend (Express Server)**: Serves API endpoints and processes chat history data
2. **Frontend (Vite + React)**: Development server with HMR
### Running in Development Mode
You need to run both the backend and frontend servers:
**Terminal 1 - Backend Server (Port 3001):**
```bash
pnpm dev:server
```
**Terminal 2 - Frontend Dev Server (Port 3000):**
```bash
pnpm dev
```
Then open <http://localhost:3000> in your browser.
The Vite dev server will proxy `/api` requests to the backend server at port 3001.
### Building for Production
```bash
pnpm build
```
This compiles TypeScript and builds the React application. The output will be in the `dist/` directory.
In production, the Express server serves both the static files and API endpoints from a single port.
## Architecture
- **Frontend**: React + TypeScript + Vite + Chart.js
- **Backend**: Express + Node.js
- **Data Source**: JSONL chat history files from `~/.qwen/projects/*/chats/`
## Original Vite Template Info
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/qwen.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qwen Code Insight</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
{
"name": "insight-page",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:server": "BASE_DIR=$HOME/.qwen/projects PORT=3001 tsx ../insightServer.ts",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@uiw/react-heat-map": "^2.3.3",
"chart.js": "^4.5.1",
"html2canvas": "^1.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "npm:rolldown-vite@7.2.5"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -0,0 +1,395 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import {
Chart,
LineController,
LineElement,
BarController,
BarElement,
CategoryScale,
LinearScale,
PointElement,
Legend,
Title,
Tooltip,
} from 'chart.js';
import type { ChartConfiguration } from 'chart.js';
import HeatMap from '@uiw/react-heat-map';
import html2canvas from 'html2canvas';
// Register Chart.js components
Chart.register(
LineController,
LineElement,
BarController,
BarElement,
CategoryScale,
LinearScale,
PointElement,
Legend,
Title,
Tooltip,
);
interface UsageMetadata {
input: number;
output: number;
total: number;
}
interface InsightData {
heatmap: { [date: string]: number };
tokenUsage: { [date: string]: UsageMetadata };
currentStreak: number;
longestStreak: number;
longestWorkDate: string | null;
longestWorkDuration: number;
activeHours: { [hour: number]: number };
latestActiveTime: string | null;
achievements: Array<{
id: string;
name: string;
description: string;
}>;
}
function App() {
const [insights, setInsights] = useState<InsightData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const hourChartRef = useRef<HTMLCanvasElement>(null);
const hourChartInstance = useRef<Chart | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Load insights data
useEffect(() => {
const loadInsights = async () => {
try {
setLoading(true);
const response = await fetch('/api/insights');
if (!response.ok) {
throw new Error('Failed to fetch insights');
}
const data: InsightData = await response.json();
setInsights(data);
setError(null);
} catch (err) {
setError((err as Error).message);
setInsights(null);
} finally {
setLoading(false);
}
};
loadInsights();
}, []);
// Create hour chart when insights change
useEffect(() => {
if (!insights || !hourChartRef.current) return;
// Destroy existing chart if it exists
if (hourChartInstance.current) {
hourChartInstance.current.destroy();
}
const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`);
const data = labels.map((_, i) => insights.activeHours[i] || 0);
const ctx = hourChartRef.current.getContext('2d');
if (!ctx) return;
hourChartInstance.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,
},
},
} as ChartConfiguration['options'],
});
}, [insights]);
const handleExport = async () => {
if (!containerRef.current) return;
try {
const button = document.getElementById('export-btn') as HTMLButtonElement;
button.style.display = 'none';
const canvas = await html2canvas(containerRef.current, {
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();
button.style.display = 'block';
} catch (err) {
console.error('Error capturing image:', err);
alert('Failed to export image. Please try again.');
}
};
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="glass-card px-8 py-6 text-center">
<h2 className="text-xl font-semibold text-slate-900">
Loading insights...
</h2>
<p className="mt-2 text-sm text-slate-600">
Fetching your coding patterns
</p>
</div>
</div>
);
}
if (error || !insights) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="glass-card px-8 py-6 text-center">
<h2 className="text-xl font-semibold text-rose-700">
Error loading insights
</h2>
<p className="mt-2 text-sm text-slate-600">
{error || 'Please try again later.'}
</p>
</div>
</div>
);
}
// Prepare heatmap data for react-heat-map
const heatmapData = Object.entries(insights.heatmap).map(([date, count]) => ({
date,
count,
}));
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="min-h-screen" ref={containerRef}>
<div className="mx-auto max-w-6xl px-6 py-10 md:py-12">
<header className="mb-8 space-y-3 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
Insights
</p>
<h1 className="text-3xl font-semibold text-slate-900 md:text-4xl">
Qwen Code Insights
</h1>
<p className="text-sm text-slate-600">
Your personalized coding journey and patterns
</p>
</header>
<div className="grid gap-4 md:grid-cols-3 md:gap-6">
<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">
{insights.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 {insights.longestStreak}d
</span>
</div>
</div>
<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={hourChartRef}></canvas>
</div>
</div>
<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">
{insights.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">
{insights.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">
{insights.latestActiveTime || '-'}
</p>
</div>
</div>
</div>
</div>
<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="overflow-x-auto">
<div className="min-w-[720px] rounded-xl border border-slate-100 bg-white/70 p-4 shadow-inner shadow-slate-100">
<HeatMap
value={heatmapData}
width={1000}
style={{ color: '#0f172a' } satisfies CSSProperties}
startDate={
new Date(new Date().setFullYear(new Date().getFullYear() - 1))
}
endDate={new Date()}
rectSize={14}
legendCellSize={12}
rectProps={{
rx: 2,
}}
panelColors={{
0: '#e2e8f0',
2: '#a5d8ff',
4: '#74c0fc',
10: '#339af0',
20: '#1c7ed6',
}}
/>
</div>
</div>
</div>
<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">
<div className="rounded-xl bg-slate-50 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Input
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{Object.values(insights.tokenUsage)
.reduce((acc, usage) => acc + usage.input, 0)
.toLocaleString()}
</p>
</div>
<div className="rounded-xl bg-slate-50 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Output
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{Object.values(insights.tokenUsage)
.reduce((acc, usage) => acc + usage.output, 0)
.toLocaleString()}
</p>
</div>
<div className="rounded-xl bg-slate-50 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Total
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{Object.values(insights.tokenUsage)
.reduce((acc, usage) => acc + usage.total, 0)
.toLocaleString()}
</p>
</div>
</div>
</div>
</div>
<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">
{insights.achievements.length} total
</span>
</div>
{insights.achievements.length === 0 ? (
<p className="text-sm text-slate-600">
No achievements yet. Keep coding!
</p>
) : (
<div className="divide-y divide-slate-200">
{insights.achievements.map((achievement) => (
<div
key={achievement.id}
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>
))}
</div>
)}
</div>
<div className="mt-6 flex justify-center">
<button
id="export-btn"
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]"
onClick={handleExport}
>
Export as Image
<span className="text-slate-200 transition group-hover:translate-x-0.5">
</span>
</button>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 text-slate-900 antialiased;
}
}
@layer components {
.glass-card {
@apply rounded-2xl border border-slate-200 bg-white/80 shadow-soft backdrop-blur;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,18 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
boxShadow: {
soft: '0 10px 40px rgba(15, 23, 42, 0.08)',
},
borderRadius: {
xl: '1.25rem',
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/qwen.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>insight-page</title>
<script type="module" crossorigin src="/assets/index-D7obW1Jn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CV6J1oXz.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: 'views',
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});

View File

@@ -0,0 +1,404 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import express from 'express';
import fs from 'fs/promises';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import type { ChatRecord } from '@qwen-code/qwen-code-core';
import { read } from '@qwen-code/qwen-code-core/src/utils/jsonl-utils.js';
interface StreakData {
currentStreak: number;
longestStreak: number;
dates: string[];
}
// For heat map data
interface HeatMapData {
[date: string]: number;
}
// For token usage data
interface TokenUsageData {
[date: string]: {
input: number;
output: number;
total: number;
};
}
// For achievement data
interface AchievementData {
id: string;
name: string;
description: string;
}
// For the final insight data
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[];
}
function debugLog(message: string) {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}\n`;
console.log(logMessage);
}
debugLog('Insight server starting...');
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env['PORT'];
const BASE_DIR = process.env['BASE_DIR'];
if (!BASE_DIR) {
debugLog('BASE_DIR environment variable is required');
process.exit(1);
}
// Serve static assets from the views/assets directory
app.use(
'/assets',
express.static(path.join(__dirname, 'insight-page', 'views', 'assets')),
);
app.get('/', (_req, res) => {
res.sendFile(path.join(__dirname, 'insight-page', 'views', 'index.html'));
});
// API endpoint to get insight data
app.get('/api/insights', async (_req, res) => {
try {
debugLog('Received request for insights data');
const insights = await generateInsights(BASE_DIR);
res.json(insights);
} catch (error) {
debugLog(`Error generating insights: ${error}`);
res.status(500).json({ error: 'Failed to generate insights' });
}
});
// Process chat files from all projects in the base directory and generate insights
async function 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') {
debugLog(
`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 read<ChatRecord>(filePath);
// Process each record
for (const record of records) {
const timestamp = new Date(record.timestamp);
const dateKey = 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
debugLog(`Base directory does not exist: ${baseDir}`);
} else {
debugLog(`Error reading base directory: ${error}`);
}
}
// Calculate streak data
const streakData = 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 = 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 = calculateAchievements(activeHours, heatmap, tokenUsage);
return {
heatmap,
tokenUsage,
currentStreak: streakData.currentStreak,
longestStreak: streakData.longestStreak,
longestWorkDate,
longestWorkDuration,
activeHours,
latestActiveTime,
achievements,
};
}
// Helper function to format date as YYYY-MM-DD
function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
// Calculate streaks from activity dates
function 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
function 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 tokens used - commented out since it's not currently used
// const totalTokens = Object.values(tokenUsage).reduce((sum, usage) => sum + usage.total, 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;
}
// Start the server
app.listen(PORT, () => {
debugLog(`Server running at http://localhost:${PORT}/`);
debugLog(`Analyzing projects in: ${BASE_DIR}`);
debugLog('Server is running. Press Ctrl+C to stop.');
});

View File

@@ -1,379 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import { exportCommand } from './exportCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { ChatRecord } from '@qwen-code/qwen-code-core';
import type { Part, Content } from '@google/genai';
import {
transformToMarkdown,
loadHtmlTemplate,
prepareExportData,
injectDataIntoHtmlTemplate,
generateExportFilename,
} from '../utils/exportUtils.js';
const mockSessionServiceMocks = vi.hoisted(() => ({
loadLastSession: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', () => {
class SessionService {
constructor(_cwd: string) {}
async loadLastSession() {
return mockSessionServiceMocks.loadLastSession();
}
}
return {
SessionService,
};
});
vi.mock('../utils/exportUtils.js', () => ({
transformToMarkdown: vi.fn(),
loadHtmlTemplate: vi.fn(),
prepareExportData: vi.fn(),
injectDataIntoHtmlTemplate: vi.fn(),
generateExportFilename: vi.fn(),
}));
vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(),
}));
describe('exportCommand', () => {
const mockSessionData = {
conversation: {
sessionId: 'test-session-id',
startTime: '2025-01-01T00:00:00Z',
messages: [
{
type: 'user',
message: {
parts: [{ text: 'Hello' }] as Part[],
} as Content,
},
] as ChatRecord[],
},
};
let mockContext: ReturnType<typeof createMockCommandContext>;
beforeEach(() => {
vi.clearAllMocks();
mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData);
mockContext = createMockCommandContext({
services: {
config: {
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
},
},
});
vi.mocked(transformToMarkdown).mockReturnValue('# Test Markdown');
vi.mocked(loadHtmlTemplate).mockResolvedValue(
'<html><script id="chat-data" type="application/json">// DATA_PLACEHOLDER</script></html>',
);
vi.mocked(prepareExportData).mockReturnValue({
sessionId: 'test-session-id',
startTime: '2025-01-01T00:00:00Z',
messages: mockSessionData.conversation.messages,
});
vi.mocked(injectDataIntoHtmlTemplate).mockReturnValue(
'<html><script id="chat-data" type="application/json">{"data": "test"}</script></html>',
);
vi.mocked(generateExportFilename).mockImplementation(
(ext: string) => `export-2025-01-01T00-00-00-000Z.${ext}`,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('command structure', () => {
it('should have correct name and description', () => {
expect(exportCommand.name).toBe('export');
expect(exportCommand.description).toBe(
'Export current session message history to a file',
);
});
it('should have md and html subcommands', () => {
expect(exportCommand.subCommands).toHaveLength(2);
expect(exportCommand.subCommands?.map((c) => c.name)).toEqual([
'md',
'html',
]);
});
});
describe('exportMarkdownAction', () => {
it('should export session to markdown file', async () => {
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
if (!mdCommand?.action) {
throw new Error('md command not found');
}
const result = await mdCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'),
});
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
expect(transformToMarkdown).toHaveBeenCalledWith(
mockSessionData.conversation.messages,
'test-session-id',
'2025-01-01T00:00:00Z',
);
expect(generateExportFilename).toHaveBeenCalledWith('md');
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'),
'# Test Markdown',
'utf-8',
);
});
it('should return error when config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
services: {
config: null,
},
});
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
if (!mdCommand?.action) {
throw new Error('md command not found');
}
const result = await mdCommand.action(contextWithoutConfig, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
});
});
it('should return error when working directory cannot be determined', async () => {
const contextWithoutCwd = createMockCommandContext({
services: {
config: {
getWorkingDir: vi.fn().mockReturnValue(null),
getProjectRoot: vi.fn().mockReturnValue(null),
},
},
});
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
if (!mdCommand || !mdCommand.action) {
throw new Error('md command not found');
}
const result = await mdCommand.action(contextWithoutCwd, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not determine current working directory.',
});
});
it('should return error when no session is found', async () => {
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
if (!mdCommand?.action) {
throw new Error('md command not found');
}
const result = await mdCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No active session found to export.',
});
});
it('should handle errors during export', async () => {
const error = new Error('File write failed');
vi.mocked(fs.writeFile).mockRejectedValue(error);
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
if (!mdCommand?.action) {
throw new Error('md command not found');
}
const result = await mdCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to export session: File write failed',
});
});
it('should use project root when working dir is not available', async () => {
const contextWithProjectRoot = createMockCommandContext({
services: {
config: {
getWorkingDir: vi.fn().mockReturnValue(null),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
},
},
});
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
if (!mdCommand?.action) {
throw new Error('md command not found');
}
await mdCommand.action(contextWithProjectRoot, '');
});
});
describe('exportHtmlAction', () => {
it('should export session to HTML file', async () => {
const htmlCommand = exportCommand.subCommands?.find(
(c) => c.name === 'html',
);
if (!htmlCommand?.action) {
throw new Error('html command not found');
}
const result = await htmlCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining(
'export-2025-01-01T00-00-00-000Z.html',
),
});
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
expect(loadHtmlTemplate).toHaveBeenCalled();
expect(prepareExportData).toHaveBeenCalledWith(
mockSessionData.conversation,
);
expect(injectDataIntoHtmlTemplate).toHaveBeenCalled();
expect(generateExportFilename).toHaveBeenCalledWith('html');
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('export-2025-01-01T00-00-00-000Z.html'),
expect.stringContaining('{"data": "test"}'),
'utf-8',
);
});
it('should return error when config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
services: {
config: null,
},
});
const htmlCommand = exportCommand.subCommands?.find(
(c) => c.name === 'html',
);
if (!htmlCommand?.action) {
throw new Error('html command not found');
}
const result = await htmlCommand.action(contextWithoutConfig, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
});
});
it('should return error when working directory cannot be determined', async () => {
const contextWithoutCwd = createMockCommandContext({
services: {
config: {
getWorkingDir: vi.fn().mockReturnValue(null),
getProjectRoot: vi.fn().mockReturnValue(null),
},
},
});
const htmlCommand = exportCommand.subCommands?.find(
(c) => c.name === 'html',
);
if (!htmlCommand || !htmlCommand.action) {
throw new Error('html command not found');
}
const result = await htmlCommand.action(contextWithoutCwd, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not determine current working directory.',
});
});
it('should return error when no session is found', async () => {
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
const htmlCommand = exportCommand.subCommands?.find(
(c) => c.name === 'html',
);
if (!htmlCommand?.action) {
throw new Error('html command not found');
}
const result = await htmlCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No active session found to export.',
});
});
it('should handle errors during HTML template loading', async () => {
const error = new Error('Failed to fetch template');
vi.mocked(loadHtmlTemplate).mockRejectedValue(error);
const htmlCommand = exportCommand.subCommands?.find(
(c) => c.name === 'html',
);
if (!htmlCommand?.action) {
throw new Error('html command not found');
}
const result = await htmlCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to export session: Failed to fetch template',
});
});
it('should handle errors during file write', async () => {
const error = new Error('File write failed');
vi.mocked(fs.writeFile).mockRejectedValue(error);
const htmlCommand = exportCommand.subCommands?.find(
(c) => c.name === 'html',
);
if (!htmlCommand?.action) {
throw new Error('html command not found');
}
const result = await htmlCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to export session: File write failed',
});
});
});
});

View File

@@ -1,177 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
type CommandContext,
type SlashCommand,
type MessageActionReturn,
CommandKind,
} from './types.js';
import { SessionService } from '@qwen-code/qwen-code-core';
import {
transformToMarkdown,
loadHtmlTemplate,
prepareExportData,
injectDataIntoHtmlTemplate,
generateExportFilename,
} from '../utils/exportUtils.js';
/**
* Action for the 'md' subcommand - exports session to markdown.
*/
async function exportMarkdownAction(
context: CommandContext,
): Promise<MessageActionReturn> {
const { services } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const cwd = config.getWorkingDir() || config.getProjectRoot();
if (!cwd) {
return {
type: 'message',
messageType: 'error',
content: 'Could not determine current working directory.',
};
}
try {
// Load the current session
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
if (!sessionData) {
return {
type: 'message',
messageType: 'error',
content: 'No active session found to export.',
};
}
const { conversation } = sessionData;
const markdown = transformToMarkdown(
conversation.messages,
conversation.sessionId,
conversation.startTime,
);
const filename = generateExportFilename('md');
const filepath = path.join(cwd, filename);
// Write to file
await fs.writeFile(filepath, markdown, 'utf-8');
return {
type: 'message',
messageType: 'info',
content: `Session exported to markdown: ${filename}`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Action for the 'html' subcommand - exports session to HTML.
*/
async function exportHtmlAction(
context: CommandContext,
): Promise<MessageActionReturn> {
const { services } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const cwd = config.getWorkingDir() || config.getProjectRoot();
if (!cwd) {
return {
type: 'message',
messageType: 'error',
content: 'Could not determine current working directory.',
};
}
try {
// Load the current session
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
if (!sessionData) {
return {
type: 'message',
messageType: 'error',
content: 'No active session found to export.',
};
}
const { conversation } = sessionData;
const template = await loadHtmlTemplate();
const exportData = prepareExportData(conversation);
const html = injectDataIntoHtmlTemplate(template, exportData);
const filename = generateExportFilename('html');
const filepath = path.join(cwd, filename);
// Write to file
await fs.writeFile(filepath, html, 'utf-8');
return {
type: 'message',
messageType: 'info',
content: `Session exported to HTML: ${filename}`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Main export command with subcommands.
*/
export const exportCommand: SlashCommand = {
name: 'export',
description: 'Export current session message history to a file',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'md',
description: 'Export session to markdown format',
kind: CommandKind.BUILT_IN,
action: exportMarkdownAction,
},
{
name: 'html',
description: 'Export session to HTML format',
kind: CommandKind.BUILT_IN,
action: exportHtmlAction,
},
],
};

View File

@@ -0,0 +1,190 @@
/**
* @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 { spawn } from 'child_process';
import { join } from 'path';
import os from 'os';
import { registerCleanup } from '../../utils/cleanup.js';
import net from 'net';
// Track the insight server subprocess so we can terminate it on quit
let insightServerProcess: import('child_process').ChildProcess | null = null;
// Find an available port starting from a default port
async function findAvailablePort(startingPort: number = 3000): Promise<number> {
return new Promise((resolve, reject) => {
let port = startingPort;
const checkPort = () => {
const server = net.createServer();
server.listen(port, () => {
server.once('close', () => {
resolve(port);
});
server.close();
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
port++; // Try next port
checkPort();
} else {
reject(err);
}
});
};
checkPort();
});
}
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('Starting insight server...'));
// If there's an existing insight server process, terminate it first
if (insightServerProcess && !insightServerProcess.killed) {
insightServerProcess.kill();
insightServerProcess = null;
}
// Find an available port
const availablePort = await findAvailablePort(3000);
const projectsDir = join(os.homedir(), '.qwen', 'projects');
// Path to the insight server script
const insightScriptPath = join(
process.cwd(),
'packages',
'cli',
'src',
'services',
'insightServer.ts',
);
// Spawn the insight server process
const serverProcess = spawn('npx', ['tsx', insightScriptPath], {
stdio: 'pipe',
env: {
...process.env,
NODE_ENV: 'production',
BASE_DIR: projectsDir,
PORT: String(availablePort),
},
});
// Store the server process for cleanup
insightServerProcess = serverProcess;
// Register cleanup function to terminate the server process on quit
registerCleanup(() => {
if (insightServerProcess && !insightServerProcess.killed) {
insightServerProcess.kill();
insightServerProcess = null;
}
});
serverProcess.stderr.on('data', (data) => {
// Forward error output to parent process stderr
process.stderr.write(`Insight server error: ${data}`);
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Insight server error: ${data.toString()}`,
},
Date.now(),
);
});
serverProcess.on('close', (code) => {
console.log(`Insight server process exited with code ${code}`);
context.ui.setDebugMessage(t('Insight server stopped.'));
// Reset the reference when the process closes
if (insightServerProcess === serverProcess) {
insightServerProcess = null;
}
});
const url = `http://localhost:${availablePort}`;
// Open browser automatically
const openBrowser = async () => {
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
switch (process.platform) {
case 'darwin': // macOS
await execAsync(`open ${url}`);
break;
case 'win32': // Windows
await execAsync(`start ${url}`);
break;
default: // Linux and others
await execAsync(`xdg-open ${url}`);
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Insight server started. Visit: ${url}`,
},
Date.now(),
);
} catch (err) {
console.error('Failed to open browser automatically:', err);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Insight server started. Please visit: ${url}`,
},
Date.now(),
);
}
};
// Wait for the server to start (give it some time to bind to the port)
setTimeout(openBrowser, 1000);
// Inform the user that the server is running
context.ui.addItem(
{
type: MessageType.INFO,
text: t(
'Insight server started. Check your browser for the visualization.',
),
},
Date.now(),
);
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to start insight server: {{error}}', {
error: (error as Error).message,
}),
},
Date.now(),
);
}
},
};

View File

@@ -1,404 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
extractTextFromContent,
transformToMarkdown,
loadHtmlTemplate,
prepareExportData,
injectDataIntoHtmlTemplate,
generateExportFilename,
} from './exportUtils.js';
import type { ChatRecord } from '@qwen-code/qwen-code-core';
import type { Part, Content } from '@google/genai';
describe('exportUtils', () => {
describe('extractTextFromContent', () => {
it('should return empty string for undefined content', () => {
expect(extractTextFromContent(undefined)).toBe('');
});
it('should return empty string for content without parts', () => {
expect(extractTextFromContent({} as Content)).toBe('');
});
it('should extract text from text parts', () => {
const content: Content = {
parts: [{ text: 'Hello' }, { text: 'World' }] as Part[],
};
expect(extractTextFromContent(content)).toBe('Hello\nWorld');
});
it('should format function call parts', () => {
const content: Content = {
parts: [
{
functionCall: {
name: 'testFunction',
args: { param1: 'value1' },
},
},
] as Part[],
};
const result = extractTextFromContent(content);
expect(result).toContain('[Function Call: testFunction]');
expect(result).toContain('"param1": "value1"');
});
it('should format function response parts', () => {
const content: Content = {
parts: [
{
functionResponse: {
name: 'testFunction',
response: { result: 'success' },
},
},
] as Part[],
};
const result = extractTextFromContent(content);
expect(result).toContain('[Function Response: testFunction]');
expect(result).toContain('"result": "success"');
});
it('should handle mixed part types', () => {
const content: Content = {
parts: [
{ text: 'Start' },
{
functionCall: {
name: 'call',
args: {},
},
},
{ text: 'End' },
] as Part[],
};
const result = extractTextFromContent(content);
expect(result).toContain('Start');
expect(result).toContain('[Function Call: call]');
expect(result).toContain('End');
});
});
describe('transformToMarkdown', () => {
const mockMessages: ChatRecord[] = [
{
uuid: 'uuid-1',
parentUuid: null,
sessionId: 'test-session-id',
timestamp: '2025-01-01T00:00:00Z',
type: 'user',
cwd: '/test',
version: '1.0.0',
message: {
parts: [{ text: 'Hello, how are you?' }] as Part[],
} as Content,
},
{
uuid: 'uuid-2',
parentUuid: 'uuid-1',
sessionId: 'test-session-id',
timestamp: '2025-01-01T00:00:01Z',
type: 'assistant',
cwd: '/test',
version: '1.0.0',
message: {
parts: [{ text: 'I am doing well, thank you!' }] as Part[],
} as Content,
},
];
it('should transform messages to markdown format', () => {
const result = transformToMarkdown(
mockMessages,
'test-session-id',
'2025-01-01T00:00:00Z',
);
expect(result).toContain('# Chat Session Export');
expect(result).toContain('**Session ID**: test-session-id');
expect(result).toContain('**Start Time**: 2025-01-01T00:00:00Z');
expect(result).toContain('## User');
expect(result).toContain('Hello, how are you?');
expect(result).toContain('## Assistant');
expect(result).toContain('I am doing well, thank you!');
});
it('should include exported timestamp', () => {
const before = new Date().toISOString();
const result = transformToMarkdown(
mockMessages,
'test-session-id',
'2025-01-01T00:00:00Z',
);
const after = new Date().toISOString();
expect(result).toContain('**Exported**:');
const exportedMatch = result.match(/\*\*Exported\*\*: (.+)/);
expect(exportedMatch).toBeTruthy();
if (exportedMatch) {
const exportedTime = exportedMatch[1].trim();
expect(exportedTime >= before).toBe(true);
expect(exportedTime <= after).toBe(true);
}
});
it('should format tool_result messages', () => {
const messages: ChatRecord[] = [
{
uuid: 'uuid-3',
parentUuid: 'uuid-2',
sessionId: 'test-session-id',
timestamp: '2025-01-01T00:00:02Z',
type: 'tool_result',
cwd: '/test',
version: '1.0.0',
toolCallResult: {
resultDisplay: 'Tool output',
},
message: {
parts: [{ text: 'Additional info' }] as Part[],
} as Content,
},
];
const result = transformToMarkdown(
messages,
'test-session-id',
'2025-01-01T00:00:00Z',
);
expect(result).toContain('## Tool Result');
expect(result).toContain('```');
expect(result).toContain('Tool output');
expect(result).toContain('Additional info');
});
it('should format tool_result with JSON resultDisplay', () => {
const messages: ChatRecord[] = [
{
uuid: 'uuid-4',
parentUuid: 'uuid-3',
sessionId: 'test-session-id',
timestamp: '2025-01-01T00:00:03Z',
type: 'tool_result',
cwd: '/test',
version: '1.0.0',
toolCallResult: {
resultDisplay: '{"key": "value"}',
},
message: {} as Content,
},
];
const result = transformToMarkdown(
messages,
'test-session-id',
'2025-01-01T00:00:00Z',
);
expect(result).toContain('## Tool Result');
expect(result).toContain('```');
expect(result).toContain('"key": "value"');
});
it('should handle chat compression system messages', () => {
const messages: ChatRecord[] = [
{
uuid: 'uuid-5',
parentUuid: null,
sessionId: 'test-session-id',
timestamp: '2025-01-01T00:00:04Z',
type: 'system',
subtype: 'chat_compression',
cwd: '/test',
version: '1.0.0',
message: {} as Content,
},
];
const result = transformToMarkdown(
messages,
'test-session-id',
'2025-01-01T00:00:00Z',
);
expect(result).toContain('_[Chat history compressed]_');
});
it('should skip system messages without subtype', () => {
const messages: ChatRecord[] = [
{
uuid: 'uuid-6',
parentUuid: null,
sessionId: 'test-session-id',
timestamp: '2025-01-01T00:00:05Z',
type: 'system',
cwd: '/test',
version: '1.0.0',
message: {} as Content,
},
];
const result = transformToMarkdown(
messages,
'test-session-id',
'2025-01-01T00:00:00Z',
);
expect(result).not.toContain('## System');
});
});
describe('loadHtmlTemplate', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('should load HTML template from URL', async () => {
const mockTemplate = '<html><body>Test Template</body></html>';
const mockResponse = {
ok: true,
text: vi.fn().mockResolvedValue(mockTemplate),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
const result = await loadHtmlTemplate();
expect(result).toBe(mockTemplate);
expect(fetch).toHaveBeenCalledWith(
'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html',
);
});
it('should throw error when fetch fails', async () => {
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found',
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
await expect(loadHtmlTemplate()).rejects.toThrow(
'Failed to fetch HTML template: 404 Not Found',
);
});
it('should throw error when network request fails', async () => {
const networkError = new Error('Network error');
vi.mocked(fetch).mockRejectedValue(networkError);
await expect(loadHtmlTemplate()).rejects.toThrow(
'Failed to load HTML template',
);
await expect(loadHtmlTemplate()).rejects.toThrow('Network error');
});
});
describe('prepareExportData', () => {
it('should prepare export data from conversation', () => {
const conversation = {
sessionId: 'test-session-id',
startTime: '2025-01-01T00:00:00Z',
messages: [
{
type: 'user',
message: {
parts: [{ text: 'Hello' }] as Part[],
} as Content,
},
] as ChatRecord[],
};
const result = prepareExportData(conversation);
expect(result).toEqual({
sessionId: 'test-session-id',
startTime: '2025-01-01T00:00:00Z',
messages: conversation.messages,
});
});
});
describe('injectDataIntoHtmlTemplate', () => {
it('should inject JSON data into HTML template', () => {
const template = `
<html>
<body>
<script id="chat-data" type="application/json">
// DATA_PLACEHOLDER: Your JSONL data will be injected here
</script>
</body>
</html>
`;
const data = {
sessionId: 'test-session-id',
startTime: '2025-01-01T00:00:00Z',
messages: [] as ChatRecord[],
};
const result = injectDataIntoHtmlTemplate(template, data);
expect(result).toContain(
'<script id="chat-data" type="application/json">',
);
expect(result).toContain('"sessionId": "test-session-id"');
expect(result).toContain('"startTime": "2025-01-01T00:00:00Z"');
expect(result).not.toContain('DATA_PLACEHOLDER');
});
it('should handle template with whitespace around placeholder', () => {
const template = `<script id="chat-data" type="application/json">\n// DATA_PLACEHOLDER: Your JSONL data will be injected here\n</script>`;
const data = {
sessionId: 'test',
startTime: '2025-01-01T00:00:00Z',
messages: [] as ChatRecord[],
};
const result = injectDataIntoHtmlTemplate(template, data);
expect(result).toContain('"sessionId": "test"');
expect(result).not.toContain('DATA_PLACEHOLDER');
});
});
describe('generateExportFilename', () => {
it('should generate filename with timestamp and extension', () => {
const filename = generateExportFilename('md');
expect(filename).toMatch(
/^export-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/,
);
});
it('should use provided extension', () => {
const filename1 = generateExportFilename('html');
const filename2 = generateExportFilename('json');
expect(filename1).toMatch(/\.html$/);
expect(filename2).toMatch(/\.json$/);
});
it('should replace colons and dots in timestamp', () => {
const filename = generateExportFilename('md');
expect(filename).not.toContain(':');
// The filename should contain a dot only for the extension
expect(filename.split('.').length).toBe(2);
// Check that timestamp part (before extension) doesn't contain dots
const timestampPart = filename.split('.')[0];
expect(timestampPart).not.toContain('.');
});
});
});

View File

@@ -1,167 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part, Content } from '@google/genai';
import type { ChatRecord } from '@qwen-code/qwen-code-core';
const HTML_TEMPLATE_URL =
'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html';
/**
* Extracts text content from a Content object's parts.
*/
export function extractTextFromContent(content: Content | undefined): string {
if (!content?.parts) return '';
const textParts: string[] = [];
for (const part of content.parts as Part[]) {
if ('text' in part) {
const textPart = part as { text: string };
textParts.push(textPart.text);
} else if ('functionCall' in part) {
const fnPart = part as { functionCall: { name: string; args: unknown } };
textParts.push(
`[Function Call: ${fnPart.functionCall.name}]\n${JSON.stringify(fnPart.functionCall.args, null, 2)}`,
);
} else if ('functionResponse' in part) {
const fnResPart = part as {
functionResponse: { name: string; response: unknown };
};
textParts.push(
`[Function Response: ${fnResPart.functionResponse.name}]\n${JSON.stringify(fnResPart.functionResponse.response, null, 2)}`,
);
}
}
return textParts.join('\n');
}
/**
* Transforms ChatRecord messages to markdown format.
*/
export function transformToMarkdown(
messages: ChatRecord[],
sessionId: string,
startTime: string,
): string {
const lines: string[] = [];
// Add header with metadata
lines.push('# Chat Session Export\n');
lines.push(`**Session ID**: ${sessionId}\n`);
lines.push(`**Start Time**: ${startTime}\n`);
lines.push(`**Exported**: ${new Date().toISOString()}\n`);
lines.push('---\n');
// Process each message
for (const record of messages) {
if (record.type === 'user') {
lines.push('## User\n');
const text = extractTextFromContent(record.message);
lines.push(`${text}\n`);
} else if (record.type === 'assistant') {
lines.push('## Assistant\n');
const text = extractTextFromContent(record.message);
lines.push(`${text}\n`);
} else if (record.type === 'tool_result') {
lines.push('## Tool Result\n');
if (record.toolCallResult) {
const resultDisplay = record.toolCallResult.resultDisplay;
if (resultDisplay) {
lines.push('```\n');
lines.push(
typeof resultDisplay === 'string'
? resultDisplay
: JSON.stringify(resultDisplay, null, 2),
);
lines.push('\n```\n');
}
}
const text = extractTextFromContent(record.message);
if (text) {
lines.push(`${text}\n`);
}
} else if (record.type === 'system') {
// Skip system messages or format them minimally
if (record.subtype === 'chat_compression') {
lines.push('_[Chat history compressed]_\n');
}
}
lines.push('\n');
}
return lines.join('');
}
/**
* Loads the HTML template from a remote URL via fetch.
* Throws an error if the fetch fails.
*/
export async function loadHtmlTemplate(): Promise<string> {
try {
const response = await fetch(HTML_TEMPLATE_URL);
if (!response.ok) {
throw new Error(
`Failed to fetch HTML template: ${response.status} ${response.statusText}`,
);
}
const template = await response.text();
return template;
} catch (error) {
throw new Error(
`Failed to load HTML template from ${HTML_TEMPLATE_URL}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/**
* Prepares export data from conversation.
*/
export function prepareExportData(conversation: {
sessionId: string;
startTime: string;
messages: ChatRecord[];
}): {
sessionId: string;
startTime: string;
messages: ChatRecord[];
} {
return {
sessionId: conversation.sessionId,
startTime: conversation.startTime,
messages: conversation.messages,
};
}
/**
* Injects JSON data into the HTML template.
*/
export function injectDataIntoHtmlTemplate(
template: string,
data: {
sessionId: string;
startTime: string;
messages: ChatRecord[];
},
): string {
const jsonData = JSON.stringify(data, null, 2);
const html = template.replace(
/<script id="chat-data" type="application\/json">\s*\/\/ DATA_PLACEHOLDER:.*?\s*<\/script>/s,
`<script id="chat-data" type="application/json">\n${jsonData}\n </script>`,
);
return html;
}
/**
* Generates a filename with timestamp for export files.
*/
export function generateExportFilename(extension: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
return `export-${timestamp}.${extension}`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.7.0",
"version": "0.7.1",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -404,7 +404,7 @@ export class Config {
private toolRegistry!: ToolRegistry;
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
private skillManager!: SkillManager;
private skillManager: SkillManager | null = null;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
@@ -672,8 +672,10 @@ export class Config {
}
this.promptRegistry = new PromptRegistry();
this.subagentManager = new SubagentManager(this);
this.skillManager = new SkillManager(this);
await this.skillManager.startWatching();
if (this.getExperimentalSkills()) {
this.skillManager = new SkillManager(this);
await this.skillManager.startWatching();
}
// Load session subagents if they were provided before initialization
if (this.sessionSubagents.length > 0) {
@@ -1439,7 +1441,7 @@ export class Config {
return this.subagentManager;
}
getSkillManager(): SkillManager {
getSkillManager(): SkillManager | null {
return this.skillManager;
}

View File

@@ -270,28 +270,28 @@ export function createContentGeneratorConfig(
}
export async function createContentGenerator(
config: ContentGeneratorConfig,
gcConfig: Config,
generatorConfig: ContentGeneratorConfig,
config: Config,
isInitialAuth?: boolean,
): Promise<ContentGenerator> {
const validation = validateModelConfig(config, false);
const validation = validateModelConfig(generatorConfig, false);
if (!validation.valid) {
throw new Error(validation.errors.map((e) => e.message).join('\n'));
}
if (config.authType === AuthType.USE_OPENAI) {
// Import OpenAIContentGenerator dynamically to avoid circular dependencies
const authType = generatorConfig.authType;
if (!authType) {
throw new Error('ContentGeneratorConfig must have an authType');
}
let baseGenerator: ContentGenerator;
if (authType === AuthType.USE_OPENAI) {
const { createOpenAIContentGenerator } = await import(
'./openaiContentGenerator/index.js'
);
// Always use OpenAIContentGenerator, logging is controlled by enableOpenAILogging flag
const generator = createOpenAIContentGenerator(config, gcConfig);
return new LoggingContentGenerator(generator, gcConfig);
}
if (config.authType === AuthType.QWEN_OAUTH) {
// Import required classes dynamically
baseGenerator = createOpenAIContentGenerator(generatorConfig, config);
} else if (authType === AuthType.QWEN_OAUTH) {
const { getQwenOAuthClient: getQwenOauthClient } = await import(
'../qwen/qwenOAuth2.js'
);
@@ -300,44 +300,38 @@ export async function createContentGenerator(
);
try {
// Get the Qwen OAuth client (now includes integrated token management)
// If this is initial auth, require cached credentials to detect missing credentials
const qwenClient = await getQwenOauthClient(
gcConfig,
config,
isInitialAuth ? { requireCachedCredentials: true } : undefined,
);
// Create the content generator with dynamic token management
const generator = new QwenContentGenerator(qwenClient, config, gcConfig);
return new LoggingContentGenerator(generator, gcConfig);
baseGenerator = new QwenContentGenerator(
qwenClient,
generatorConfig,
config,
);
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (config.authType === AuthType.USE_ANTHROPIC) {
} else if (authType === AuthType.USE_ANTHROPIC) {
const { createAnthropicContentGenerator } = await import(
'./anthropicContentGenerator/index.js'
);
const generator = createAnthropicContentGenerator(config, gcConfig);
return new LoggingContentGenerator(generator, gcConfig);
}
if (
config.authType === AuthType.USE_GEMINI ||
config.authType === AuthType.USE_VERTEX_AI
baseGenerator = createAnthropicContentGenerator(generatorConfig, config);
} else if (
authType === AuthType.USE_GEMINI ||
authType === AuthType.USE_VERTEX_AI
) {
const { createGeminiContentGenerator } = await import(
'./geminiContentGenerator/index.js'
);
const generator = createGeminiContentGenerator(config, gcConfig);
return new LoggingContentGenerator(generator, gcConfig);
baseGenerator = createGeminiContentGenerator(generatorConfig, config);
} else {
throw new Error(
`Error creating contentGenerator: Unsupported authType: ${authType}`,
);
}
throw new Error(
`Error creating contentGenerator: Unsupported authType: ${config.authType}`,
);
return new LoggingContentGenerator(baseGenerator, config, generatorConfig);
}

View File

@@ -12,6 +12,7 @@ import type {
import { GenerateContentResponse } from '@google/genai';
import type { Config } from '../../config/config.js';
import type { ContentGenerator } from '../contentGenerator.js';
import { AuthType } from '../contentGenerator.js';
import { LoggingContentGenerator } from './index.js';
import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js';
import {
@@ -50,14 +51,17 @@ const convertGeminiResponseToOpenAISpy = vi
choices: [],
} as OpenAI.Chat.ChatCompletion);
const createConfig = (overrides: Record<string, unknown> = {}): Config =>
({
getContentGeneratorConfig: () => ({
authType: 'openai',
enableOpenAILogging: false,
...overrides,
}),
}) as Config;
const createConfig = (overrides: Record<string, unknown> = {}): Config => {
const configContent = {
authType: 'openai',
enableOpenAILogging: false,
...overrides,
};
return {
getContentGeneratorConfig: () => configContent,
getAuthType: () => configContent.authType as AuthType | undefined,
} as Config;
};
const createWrappedGenerator = (
generateContent: ContentGenerator['generateContent'],
@@ -124,13 +128,17 @@ describe('LoggingContentGenerator', () => {
),
vi.fn(),
);
const generatorConfig = {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
schemaCompliance: 'openapi_30' as const,
};
const generator = new LoggingContentGenerator(
wrapped,
createConfig({
enableOpenAILogging: true,
openAILoggingDir: 'logs',
schemaCompliance: 'openapi_30',
}),
createConfig(),
generatorConfig,
);
const request = {
@@ -225,9 +233,15 @@ describe('LoggingContentGenerator', () => {
vi.fn().mockRejectedValue(error),
vi.fn(),
);
const generatorConfig = {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
};
const generator = new LoggingContentGenerator(
wrapped,
createConfig({ enableOpenAILogging: true }),
createConfig(),
generatorConfig,
);
const request = {
@@ -293,9 +307,15 @@ describe('LoggingContentGenerator', () => {
})(),
),
);
const generatorConfig = {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
};
const generator = new LoggingContentGenerator(
wrapped,
createConfig({ enableOpenAILogging: true }),
createConfig(),
generatorConfig,
);
const request = {
@@ -345,9 +365,15 @@ describe('LoggingContentGenerator', () => {
})(),
),
);
const generatorConfig = {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
};
const generator = new LoggingContentGenerator(
wrapped,
createConfig({ enableOpenAILogging: true }),
createConfig(),
generatorConfig,
);
const request = {

View File

@@ -31,7 +31,10 @@ import {
logApiRequest,
logApiResponse,
} from '../../telemetry/loggers.js';
import type { ContentGenerator } from '../contentGenerator.js';
import type {
ContentGenerator,
ContentGeneratorConfig,
} from '../contentGenerator.js';
import { isStructuredError } from '../../utils/quotaErrorDetection.js';
import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js';
import { OpenAILogger } from '../../utils/openaiLogger.js';
@@ -50,9 +53,11 @@ export class LoggingContentGenerator implements ContentGenerator {
constructor(
private readonly wrapped: ContentGenerator,
private readonly config: Config,
generatorConfig: ContentGeneratorConfig,
) {
const generatorConfig = this.config.getContentGeneratorConfig();
if (generatorConfig?.enableOpenAILogging) {
// Extract fields needed for initialization from passed config
// (config.getContentGeneratorConfig() may not be available yet during refreshAuth)
if (generatorConfig.enableOpenAILogging) {
this.openaiLogger = new OpenAILogger(generatorConfig.openAILoggingDir);
this.schemaCompliance = generatorConfig.schemaCompliance;
}
@@ -89,7 +94,7 @@ export class LoggingContentGenerator implements ContentGenerator {
model,
durationMs,
prompt_id,
this.config.getContentGeneratorConfig()?.authType,
this.config.getAuthType(),
usageMetadata,
responseText,
),
@@ -126,7 +131,7 @@ export class LoggingContentGenerator implements ContentGenerator {
errorMessage,
durationMs,
prompt_id,
this.config.getContentGeneratorConfig()?.authType,
this.config.getAuthType(),
errorType,
errorStatus,
),

View File

@@ -235,6 +235,7 @@ export class SkillManager {
}
this.watchStarted = true;
await this.ensureUserSkillsDir();
await this.refreshCache();
this.updateWatchersFromCache();
}
@@ -486,29 +487,14 @@ export class SkillManager {
}
private updateWatchersFromCache(): void {
const desiredPaths = new Set<string>();
for (const level of ['project', 'user'] as const) {
const baseDir = this.getSkillsBaseDir(level);
const parentDir = path.dirname(baseDir);
if (fsSync.existsSync(parentDir)) {
desiredPaths.add(parentDir);
}
if (fsSync.existsSync(baseDir)) {
desiredPaths.add(baseDir);
}
const levelSkills = this.skillsCache?.get(level) || [];
for (const skill of levelSkills) {
const skillDir = path.dirname(skill.filePath);
if (fsSync.existsSync(skillDir)) {
desiredPaths.add(skillDir);
}
}
}
const watchTargets = new Set<string>(
(['project', 'user'] as const)
.map((level) => this.getSkillsBaseDir(level))
.filter((baseDir) => fsSync.existsSync(baseDir)),
);
for (const existingPath of this.watchers.keys()) {
if (!desiredPaths.has(existingPath)) {
if (!watchTargets.has(existingPath)) {
void this.watchers
.get(existingPath)
?.close()
@@ -522,7 +508,7 @@ export class SkillManager {
}
}
for (const watchPath of desiredPaths) {
for (const watchPath of watchTargets) {
if (this.watchers.has(watchPath)) {
continue;
}
@@ -557,4 +543,16 @@ export class SkillManager {
void this.refreshCache().then(() => this.updateWatchersFromCache());
}, 150);
}
private async ensureUserSkillsDir(): Promise<void> {
const baseDir = this.getSkillsBaseDir('user');
try {
await fs.mkdir(baseDir, { recursive: true });
} catch (error) {
console.warn(
`Failed to create user skills directory at ${baseDir}:`,
error,
);
}
}
}

View File

@@ -53,7 +53,7 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
false, // canUpdateOutput
);
this.skillManager = config.getSkillManager();
this.skillManager = config.getSkillManager()!;
this.skillManager.addChangeListener(() => {
void this.refreshSkills();
});

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.0",
"version": "0.7.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -1,6 +1,11 @@
# Qwen Code Companion
Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive interface. This extension bundles everything you need to get started immediately.
[![Version](https://img.shields.io/visual-studio-marketplace/v/qwenlm.qwen-code-vscode-ide-companion)](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion)
[![VS Code Installs](https://img.shields.io/visual-studio-marketplace/i/qwenlm.qwen-code-vscode-ide-companion)](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion)
[![Open VSX Downloads](https://img.shields.io/open-vsx/dt/qwenlm/qwen-code-vscode-ide-companion)](https://open-vsx.org/extension/qwenlm/qwen-code-vscode-ide-companion)
[![Rating](https://img.shields.io/visual-studio-marketplace/r/qwenlm.qwen-code-vscode-ide-companion)](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion)
Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive chat interface. This extension bundles everything you need — no additional installation required.
## Demo
@@ -11,7 +16,7 @@ Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visua
## Features
- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon
- **Native IDE experience**: Dedicated Qwen Code Chat panel accessed via the Qwen icon in the editor title bar
- **Native diffing**: Review, edit, and accept changes in VS Code's diff view
- **Auto-accept edits mode**: Automatically apply Qwen's changes as they're made
- **File management**: @-mention files or attach files and images using the system file picker
@@ -20,73 +25,46 @@ Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visua
## Requirements
- Visual Studio Code 1.85.0 or newer
- Visual Studio Code 1.85.0 or newer (also works with Cursor, Windsurf, and other VS Code-based editors)
## Installation
## Quick Start
1. Install from the VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion
1. **Install** from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) or [Open VSX Registry](https://open-vsx.org/extension/qwenlm/qwen-code-vscode-ide-companion)
2. Two ways to use
- Chat panel: Click the Qwen icon in the Activity Bar, or run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`).
- Terminal session (classic): Run `Qwen Code: Run` to launch a session in the integrated terminal (bundled CLI).
2. **Open the Chat panel** using one of these methods:
- Click the **Qwen icon** in the top-right corner of the editor
- Run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`)
## Development and Debugging
3. **Start chatting** — Ask Qwen to help with coding tasks, explain code, fix bugs, or write new features
To debug and develop this extension locally:
## Commands
1. **Clone the repository**
| Command | Description |
| -------------------------------- | ------------------------------------------------------ |
| `Qwen Code: Open` | Open the Qwen Code Chat panel |
| `Qwen Code: Run` | Launch a classic terminal session with the bundled CLI |
| `Qwen Code: Accept Current Diff` | Accept the currently displayed diff |
| `Qwen Code: Close Diff Editor` | Close/reject the current diff |
```bash
git clone https://github.com/QwenLM/qwen-code.git
cd qwen-code
```
## Feedback & Issues
2. **Install dependencies**
- 🐛 [Report bugs](https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&labels=bug,vscode-ide-companion)
- 💡 [Request features](https://github.com/QwenLM/qwen-code/issues/new?template=feature_request.yml&labels=enhancement,vscode-ide-companion)
- 📖 [Documentation](https://qwenlm.github.io/qwen-code-docs/)
- 📋 [Changelog](https://github.com/QwenLM/qwen-code/releases)
```bash
npm install
# or if using pnpm
pnpm install
```
## Contributing
3. **Start debugging**
We welcome contributions! See our [Contributing Guide](https://github.com/QwenLM/qwen-code/blob/main/CONTRIBUTING.md) for details on:
```bash
code . # Open the project root in VS Code
```
- Open the `packages/vscode-ide-companion/src/extension.ts` file
- Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`)
- Select **"Launch Companion VS Code Extension"** from the debug dropdown
- Press `F5` to launch Extension Development Host
4. **Make changes and reload**
- Edit the source code in the original VS Code window
- To see your changes, reload the Extension Development Host window by:
- Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS)
- Or clicking the "Reload" button in the debug toolbar
5. **View logs and debug output**
- Open the Debug Console in the original VS Code window to see extension logs
- In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs
## Build for Production
To build the extension for distribution:
```bash
npm run compile
# or
pnpm run compile
```
To package the extension as a VSIX file:
```bash
npx vsce package
# or
pnpm vsce package
```
- Setting up the development environment
- Building and debugging the extension locally
- Submitting pull requests
## Terms of Service and Privacy Notice
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).
## License
[Apache-2.0](https://github.com/QwenLM/qwen-code/blob/main/LICENSE)

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.7.0",
"version": "0.7.1",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {

View File

@@ -314,34 +314,32 @@ export async function activate(context: vscode.ExtensionContext) {
'cli.js',
).fsPath;
const execPath = process.execPath;
const lowerExecPath = execPath.toLowerCase();
const needsElectronRunAsNode =
lowerExecPath.includes('code') ||
lowerExecPath.includes('electron');
let qwenCmd: string;
const terminalOptions: vscode.TerminalOptions = {
name: `Qwen Code (${selectedFolder.name})`,
cwd: selectedFolder.uri.fsPath,
location,
};
let qwenCmd: string;
if (isWindows) {
// Use system Node via cmd.exe; avoid PowerShell parsing issues
// On Windows, try multiple strategies to find a Node.js runtime:
// 1. Check if VSCode ships a standalone node.exe alongside Code.exe
// 2. Check VSCode's internal Node.js in resources directory
// 3. Fall back to using Code.exe with ELECTRON_RUN_AS_NODE=1
const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`;
const cliQuoted = quoteCmd(cliEntry);
// TODO: @yiliang114, temporarily run through node, and later hope to decouple from the local node
qwenCmd = `node ${cliQuoted}`;
terminalOptions.shellPath = process.env.ComSpec;
} else {
// macOS/Linux: All VSCode-like IDEs (VSCode, Cursor, Windsurf, etc.)
// are Electron-based, so we always need ELECTRON_RUN_AS_NODE=1
// to run Node.js scripts using the IDE's bundled runtime.
const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`;
const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`;
if (needsElectronRunAsNode) {
// macOS Electron helper needs ELECTRON_RUN_AS_NODE=1;
qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`;
} else {
qwenCmd = baseCmd;
}
qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`;
}
const terminal = vscode.window.createTerminal(terminalOptions);