mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 04:30:48 +00:00
feat(insight): Refactor code structure for improved readability and maintainability
This commit is contained in:
parent
2edce464ae
commit
e66c203cb0
18 changed files with 423 additions and 229 deletions
|
|
@ -6,7 +6,10 @@
|
|||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { read as readJsonlFile } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
read as readJsonlFile,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import pLimit from 'p-limit';
|
||||
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
|
|
@ -40,6 +43,8 @@ import {
|
|||
ANALYSIS_PROMPT,
|
||||
} from '../prompts/InsightPrompts.js';
|
||||
|
||||
const logger = createDebugLogger('DataProcessor');
|
||||
|
||||
export class DataProcessor {
|
||||
constructor(private config: Config) {}
|
||||
|
||||
|
|
@ -194,7 +199,7 @@ export class DataProcessor {
|
|||
session_id: records[0].sessionId,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
`Failed to analyze session ${records[0]?.sessionId}:`,
|
||||
error,
|
||||
);
|
||||
|
|
@ -362,7 +367,7 @@ export class DataProcessor {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
console.log('Generating qualitative insights...');
|
||||
logger.info('Generating qualitative insights...');
|
||||
|
||||
const commonData = this.prepareCommonPromptData(metrics, facets);
|
||||
|
||||
|
|
@ -380,7 +385,7 @@ export class DataProcessor {
|
|||
});
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate insight:', error);
|
||||
logger.error('Failed to generate insight:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -620,39 +625,6 @@ export class DataProcessor {
|
|||
),
|
||||
]);
|
||||
|
||||
console.log(
|
||||
'🚀 ~ DataProcessor ~ generateQualitativeInsights ~ impressiveWorkflows:',
|
||||
impressiveWorkflows,
|
||||
);
|
||||
console.log(
|
||||
'🚀 ~ DataProcessor ~ generateQualitativeInsights ~ atAGlance:',
|
||||
atAGlance,
|
||||
);
|
||||
console.log(
|
||||
'🚀 ~ DataProcessor ~ generateQualitativeInsights ~ interactionStyle:',
|
||||
interactionStyle,
|
||||
);
|
||||
console.log(
|
||||
'🚀 ~ DataProcessor ~ generateQualitativeInsights ~ improvements:',
|
||||
improvements,
|
||||
);
|
||||
console.log(
|
||||
'🚀 ~ DataProcessor ~ generateQualitativeInsights ~ memorableMoment:',
|
||||
memorableMoment,
|
||||
);
|
||||
console.log(
|
||||
'🚀 ~ DataProcessor ~ generateQualitativeInsights ~ frictionPoints:',
|
||||
frictionPoints,
|
||||
);
|
||||
console.log(
|
||||
'🚀 ~ DataProcessor ~ generateQualitativeInsights ~ futureOpportunities:',
|
||||
futureOpportunities,
|
||||
);
|
||||
console.log(
|
||||
'🚀 ~ DataProcessor ~ generateQualitativeInsights ~ projectAreas:',
|
||||
projectAreas,
|
||||
);
|
||||
|
||||
return {
|
||||
impressiveWorkflows,
|
||||
projectAreas,
|
||||
|
|
@ -664,7 +636,7 @@ export class DataProcessor {
|
|||
atAGlance,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Error generating qualitative insights:', e);
|
||||
logger.error('Error generating qualitative insights:', e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -783,12 +755,12 @@ None captured`;
|
|||
const fileStats = await fs.stat(filePath);
|
||||
allChatFiles.push({ path: filePath, mtime: fileStats.mtimeMs });
|
||||
} catch (e) {
|
||||
console.error(`Failed to stat file ${filePath}:`, e);
|
||||
logger.error(`Failed to stat file ${filePath}:`, e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.log(
|
||||
logger.error(
|
||||
`Error reading chats directory for project ${projectDir}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -800,9 +772,9 @@ None captured`;
|
|||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// Base directory doesn't exist, return empty
|
||||
console.log(`Base directory does not exist: ${baseDir}`);
|
||||
logger.info(`Base directory does not exist: ${baseDir}`);
|
||||
} else {
|
||||
console.log(`Error reading base directory: ${error}`);
|
||||
logger.error(`Error reading base directory: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -898,7 +870,7 @@ None captured`;
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
`Failed to process metrics for file ${fileInfo.path}:`,
|
||||
error,
|
||||
);
|
||||
|
|
@ -994,10 +966,10 @@ None captured`;
|
|||
.sort((a, b) => b.mtime - a.mtime)
|
||||
.slice(0, 50);
|
||||
|
||||
console.log(`Analyzing ${recentFiles.length} recent sessions with LLM...`);
|
||||
logger.info(`Analyzing ${recentFiles.length} recent sessions with LLM...`);
|
||||
|
||||
// Create a limit function with concurrency of 4 to avoid 429 errors
|
||||
const limit = pLimit(4);
|
||||
const limit = pLimit(2);
|
||||
|
||||
let completed = 0;
|
||||
const total = recentFiles.length;
|
||||
|
|
@ -1036,7 +1008,7 @@ None captured`;
|
|||
} catch (readError) {
|
||||
// File doesn't exist or is invalid, proceed to analyze
|
||||
if ((readError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`Failed to read existing facet for ${sessionId}, regenerating:`,
|
||||
readError,
|
||||
);
|
||||
|
|
@ -1059,7 +1031,7 @@ None captured`;
|
|||
'utf-8',
|
||||
);
|
||||
} catch (writeError) {
|
||||
console.error(
|
||||
logger.error(
|
||||
`Failed to write facet file for session ${facet.session_id}:`,
|
||||
writeError,
|
||||
);
|
||||
|
|
@ -1074,7 +1046,7 @@ None captured`;
|
|||
|
||||
return facet;
|
||||
} catch (e) {
|
||||
console.error(`Error analyzing session file ${fileInfo.path}:`, e);
|
||||
logger.error(`Error analyzing session file ${fileInfo.path}:`, e);
|
||||
completed++;
|
||||
if (onProgress) {
|
||||
const percent = 20 + Math.round((completed / total) * 60);
|
||||
|
|
|
|||
|
|
@ -37,33 +37,25 @@ export class StaticInsightGenerator {
|
|||
baseDir: string,
|
||||
onProgress?: InsightProgressCallback,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Ensure output directory exists
|
||||
const outputDir = await this.ensureOutputDirectory();
|
||||
const facetsDir = path.join(outputDir, 'facets');
|
||||
await fs.mkdir(facetsDir, { recursive: true });
|
||||
// Ensure output directory exists
|
||||
const outputDir = await this.ensureOutputDirectory();
|
||||
const facetsDir = path.join(outputDir, 'facets');
|
||||
await fs.mkdir(facetsDir, { recursive: true });
|
||||
|
||||
// Process data
|
||||
console.log('Processing insight data...');
|
||||
const insights: InsightData = await this.dataProcessor.generateInsights(
|
||||
baseDir,
|
||||
facetsDir,
|
||||
onProgress,
|
||||
);
|
||||
// Process data
|
||||
const insights: InsightData = await this.dataProcessor.generateInsights(
|
||||
baseDir,
|
||||
facetsDir,
|
||||
onProgress,
|
||||
);
|
||||
|
||||
// Render HTML
|
||||
console.log('Rendering HTML template...');
|
||||
const html = await this.templateRenderer.renderInsightHTML(insights);
|
||||
// Render HTML
|
||||
const html = await this.templateRenderer.renderInsightHTML(insights);
|
||||
|
||||
const outputPath = path.join(outputDir, 'insight.html');
|
||||
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');
|
||||
return outputPath;
|
||||
} catch (error) {
|
||||
console.log(`Error generating static insight: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
// Write the HTML file
|
||||
await fs.writeFile(outputPath, html, 'utf-8');
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,80 +4,48 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { INSIGHT_JS, INSIGHT_CSS } from '../templates/insightTemplate.js';
|
||||
import type { InsightData } from '../types/StaticInsightTypes.js';
|
||||
|
||||
export class TemplateRenderer {
|
||||
private templateDir: string;
|
||||
|
||||
constructor() {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// In bundled version (dist/cli.js), __dirname is dist/, templates at dist/templates/
|
||||
// In development (dist/src/services/insight/generators/), templates at dist/src/services/insight/templates/
|
||||
const bundledTemplatePath = path.join(__dirname, 'templates');
|
||||
const devTemplatePath = path.join(__dirname, '..', 'templates');
|
||||
|
||||
// Try bundled path first (for production), fall back to dev path
|
||||
try {
|
||||
// Check if bundled templates exist
|
||||
if (existsSync(bundledTemplatePath)) {
|
||||
this.templateDir = bundledTemplatePath;
|
||||
} else {
|
||||
this.templateDir = devTemplatePath;
|
||||
}
|
||||
} catch {
|
||||
// If check fails, use dev path as fallback
|
||||
this.templateDir = devTemplatePath;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 componentsDir = path.join(this.templateDir, 'scripts', 'components');
|
||||
|
||||
const componentFiles = [
|
||||
'utils.js',
|
||||
'Header.js',
|
||||
'Qualitative.js',
|
||||
'Charts.js',
|
||||
'App.js',
|
||||
];
|
||||
|
||||
const scripts = await Promise.all(
|
||||
componentFiles.map((file) =>
|
||||
fs.readFile(path.join(componentsDir, file), 'utf-8'),
|
||||
),
|
||||
);
|
||||
|
||||
return scripts.join('\n\n');
|
||||
}
|
||||
|
||||
// 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();
|
||||
const html = `<!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>
|
||||
${INSIGHT_CSS}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen" id="container">
|
||||
<div class="mx-auto max-w-6xl px-6 py-10 md:py-12">
|
||||
<div id="react-root"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 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);
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
window.INSIGHT_DATA = ${JSON.stringify(insights)};
|
||||
</script>
|
||||
|
||||
<!-- App Script -->
|
||||
<script>
|
||||
${INSIGHT_JS}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
<!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">
|
||||
<!-- 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>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,139 +0,0 @@
|
|||
/* eslint-disable react/jsx-no-undef */
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
// Main App Component
|
||||
function InsightApp({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-center text-slate-600">
|
||||
No insight data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate date range
|
||||
const heatmapKeys = Object.keys(data.heatmap || {});
|
||||
let dateRangeStr = '';
|
||||
if (heatmapKeys.length > 0) {
|
||||
const dates = heatmapKeys.map((d) => new Date(d));
|
||||
const minDate = new Date(Math.min(...dates));
|
||||
const maxDate = new Date(Math.max(...dates));
|
||||
const formatDate = (d) => d.toISOString().split('T')[0];
|
||||
dateRangeStr = `${formatDate(minDate)} to ${formatDate(maxDate)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header data={data} dateRangeStr={dateRangeStr} />
|
||||
|
||||
{data.qualitative && (
|
||||
<>
|
||||
<AtAGlance qualitative={data.qualitative} />
|
||||
<NavToc />
|
||||
</>
|
||||
)}
|
||||
|
||||
<StatsRow data={data} />
|
||||
|
||||
{data.qualitative && (
|
||||
<>
|
||||
<ProjectAreas
|
||||
qualitative={data.qualitative}
|
||||
topGoals={data.topGoals}
|
||||
topTools={data.topTools}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.qualitative && (
|
||||
<>
|
||||
<InteractionStyle qualitative={data.qualitative} insights={data} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.qualitative && (
|
||||
<>
|
||||
<ImpressiveWorkflows
|
||||
qualitative={data.qualitative}
|
||||
primarySuccess={data.primarySuccess}
|
||||
outcomes={data.outcomes}
|
||||
/>
|
||||
<FrictionPoints
|
||||
qualitative={data.qualitative}
|
||||
satisfaction={data.satisfaction}
|
||||
friction={data.friction}
|
||||
/>
|
||||
<Improvements qualitative={data.qualitative} />
|
||||
<FutureOpportunities qualitative={data.qualitative} />
|
||||
<MemorableMoment qualitative={data.qualitative} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExportButton />
|
||||
</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,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Existing Components
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 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"
|
||||
width={cellSize}
|
||||
>
|
||||
More
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
// Header Component
|
||||
function Header({ data, dateRangeStr }) {
|
||||
const { totalMessages, totalSessions } = data;
|
||||
|
||||
return (
|
||||
<header className="mb-8 space-y-3 text-center">
|
||||
<h1 className="text-3xl font-semibold text-slate-900 md:text-4xl">
|
||||
Qwen Code Insights
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600">
|
||||
{totalMessages
|
||||
? `${totalMessages} messages across ${totalSessions} sessions`
|
||||
: 'Your personalized coding journey and patterns'}
|
||||
{dateRangeStr && ` | ${dateRangeStr}`}
|
||||
</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsRow({ data }) {
|
||||
const {
|
||||
totalMessages = 0,
|
||||
totalLinesAdded = 0,
|
||||
totalLinesRemoved = 0,
|
||||
totalFiles = 0,
|
||||
// totalSessions = 0,
|
||||
// totalHours = 0,
|
||||
} = data;
|
||||
|
||||
const heatmapKeys = Object.keys(data.heatmap || {});
|
||||
let daysSpan = 0;
|
||||
if (heatmapKeys.length > 0) {
|
||||
const dates = heatmapKeys.map((d) => new Date(d));
|
||||
const minDate = new Date(Math.min(...dates));
|
||||
const maxDate = new Date(Math.max(...dates));
|
||||
const diffTime = Math.abs(maxDate - minDate);
|
||||
daysSpan = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
const msgsPerDay = daysSpan > 0 ? Math.round(totalMessages / daysSpan) : 0;
|
||||
|
||||
return (
|
||||
<div className="stats-row">
|
||||
<div className="stat">
|
||||
<div className="stat-value">{totalMessages}</div>
|
||||
<div className="stat-label">Messages</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-value">
|
||||
+{totalLinesAdded}/-{totalLinesRemoved}
|
||||
</div>
|
||||
<div className="stat-label">Lines</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-value">{totalFiles}</div>
|
||||
<div className="stat-label">Files</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-value">{daysSpan}</div>
|
||||
<div className="stat-label">Days</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-value">{msgsPerDay}</div>
|
||||
<div className="stat-label">Msgs/Day</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,659 +0,0 @@
|
|||
/* eslint-disable react/jsx-no-undef */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Qualitative Insight Components
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function AtAGlance({ qualitative }) {
|
||||
const { atAGlance } = qualitative;
|
||||
if (!atAGlance) return null;
|
||||
|
||||
return (
|
||||
<div className="at-a-glance">
|
||||
<div className="glance-title">At a Glance</div>
|
||||
<div className="glance-sections">
|
||||
<div className="glance-section">
|
||||
<strong>What's working:</strong>{' '}
|
||||
<MarkdownText>{atAGlance.whats_working}</MarkdownText>
|
||||
<a href="#section-wins" className="see-more">
|
||||
Impressive Things You Did →
|
||||
</a>
|
||||
</div>
|
||||
<div className="glance-section">
|
||||
<strong>What's hindering you:</strong>{' '}
|
||||
<MarkdownText>{atAGlance.whats_hindering}</MarkdownText>
|
||||
<a href="#section-friction" className="see-more">
|
||||
Where Things Go Wrong →
|
||||
</a>
|
||||
</div>
|
||||
<div className="glance-section">
|
||||
<strong>Quick wins to try:</strong>{' '}
|
||||
<MarkdownText>{atAGlance.quick_wins}</MarkdownText>
|
||||
<a href="#section-features" className="see-more">
|
||||
Features to Try →
|
||||
</a>
|
||||
</div>
|
||||
<div className="glance-section">
|
||||
<strong>Ambitious workflows:</strong>{' '}
|
||||
<MarkdownText>{atAGlance.ambitious_workflows}</MarkdownText>
|
||||
<a href="#section-horizon" className="see-more">
|
||||
On the Horizon →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavToc() {
|
||||
return (
|
||||
<nav className="nav-toc">
|
||||
<a href="#section-work">What You Work On</a>
|
||||
<a href="#section-usage">How You Use QC</a>
|
||||
<a href="#section-wins">Impressive Things</a>
|
||||
<a href="#section-friction">Where Things Go Wrong</a>
|
||||
<a href="#section-features">Features to Try</a>
|
||||
<a href="#section-patterns">New Usage Patterns</a>
|
||||
<a href="#section-horizon">On the Horizon</a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectAreas({ qualitative, topGoals, topTools }) {
|
||||
const { projectAreas } = qualitative;
|
||||
|
||||
// Convert topTools (array of tuples) to object for chart if needed
|
||||
const topToolsObj = Array.isArray(topTools)
|
||||
? Object.fromEntries(topTools)
|
||||
: topTools;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2
|
||||
id="section-work"
|
||||
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
|
||||
>
|
||||
What You Work On
|
||||
</h2>
|
||||
|
||||
{Array.isArray(projectAreas?.areas) && projectAreas.areas.length > 0 && (
|
||||
<div className="project-areas mb-6">
|
||||
{projectAreas.areas.map((area, idx) => (
|
||||
<div key={idx} className="project-area">
|
||||
<div className="area-header">
|
||||
<span className="area-name">{area.name}</span>
|
||||
<span className="area-count">
|
||||
~{area.session_count} sessions
|
||||
</span>
|
||||
</div>
|
||||
<div className="area-desc">
|
||||
<MarkdownText>{area.description}</MarkdownText>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '24px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{topGoals && Object.keys(topGoals).length > 0 && (
|
||||
<HorizontalBarChart
|
||||
data={topGoals}
|
||||
title="What You Wanted"
|
||||
color="#0ea5e9"
|
||||
/>
|
||||
)}
|
||||
{topToolsObj && Object.keys(topToolsObj).length > 0 && (
|
||||
<HorizontalBarChart
|
||||
data={topToolsObj}
|
||||
title="Top Tools Used"
|
||||
color="#6366f1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InteractionStyle({ qualitative, insights }) {
|
||||
const { interactionStyle } = qualitative;
|
||||
if (!interactionStyle) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2
|
||||
id="section-usage"
|
||||
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
|
||||
>
|
||||
How You Use Qwen Code
|
||||
</h2>
|
||||
<div className="narrative">
|
||||
<p>
|
||||
<MarkdownText>{interactionStyle.narrative}</MarkdownText>
|
||||
</p>
|
||||
{interactionStyle.key_pattern && (
|
||||
<div className="key-insight">
|
||||
<strong>Key pattern:</strong>{' '}
|
||||
<MarkdownText>{interactionStyle.key_pattern}</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DashboardCards insights={insights} />
|
||||
<HeatmapSection heatmap={insights.heatmap} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImpressiveWorkflows({ qualitative, primarySuccess, outcomes }) {
|
||||
const { impressiveWorkflows } = qualitative;
|
||||
if (!impressiveWorkflows) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2
|
||||
id="section-wins"
|
||||
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
|
||||
>
|
||||
Impressive Things You Did
|
||||
</h2>
|
||||
{impressiveWorkflows.intro && (
|
||||
<p className="section-intro">
|
||||
<MarkdownText>{impressiveWorkflows.intro}</MarkdownText>
|
||||
</p>
|
||||
)}
|
||||
<div className="big-wins">
|
||||
{Array.isArray(impressiveWorkflows.impressive_workflows) &&
|
||||
impressiveWorkflows.impressive_workflows.map((win, idx) => (
|
||||
<div key={idx} className="big-win">
|
||||
<div className="big-win-title">{win.title}</div>
|
||||
<div className="big-win-desc">
|
||||
<MarkdownText>{win.description}</MarkdownText>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '24px',
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{primarySuccess && Object.keys(primarySuccess).length > 0 && (
|
||||
<HorizontalBarChart
|
||||
data={primarySuccess}
|
||||
title="What Helped Most (Qwen's Capabilities)"
|
||||
color="#3b82f6"
|
||||
allowedKeys={[
|
||||
'fast_accurate_search',
|
||||
'correct_code_edits',
|
||||
'good_explanations',
|
||||
'proactive_help',
|
||||
'multi_file_changes',
|
||||
'good_debugging',
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{outcomes && Object.keys(outcomes).length > 0 && (
|
||||
<HorizontalBarChart
|
||||
data={outcomes}
|
||||
title="Outcomes"
|
||||
color="#8b5cf6"
|
||||
allowedKeys={[
|
||||
'fully_achieved',
|
||||
'mostly_achieved',
|
||||
'partially_achieved',
|
||||
'not_achieved',
|
||||
'unclear_from_transcript',
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Format label for display (capitalize and replace underscores with spaces)
|
||||
function formatLabel(label) {
|
||||
if (label === 'unclear_from_transcript') {
|
||||
return 'Unclear';
|
||||
}
|
||||
return label
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Horizontal Bar Chart Component
|
||||
function HorizontalBarChart({
|
||||
data,
|
||||
title,
|
||||
color = '#3b82f6',
|
||||
allowedKeys = null,
|
||||
}) {
|
||||
if (!data || Object.keys(data).length === 0) return null;
|
||||
|
||||
// Filter and sort entries
|
||||
let entries = Object.entries(data);
|
||||
if (allowedKeys) {
|
||||
entries = entries.filter(([key]) => allowedKeys.includes(key));
|
||||
}
|
||||
entries.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const maxValue = Math.max(...entries.map(([, count]) => count));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bar-chart-card"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
color: '#64748b',
|
||||
marginTop: 0,
|
||||
marginBottom: '16px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div
|
||||
className="bar-chart"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}
|
||||
>
|
||||
{entries.map(([label, count]) => {
|
||||
const percentage = maxValue > 0 ? (count / maxValue) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className="bar-row"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
|
||||
>
|
||||
<div
|
||||
className="bar-label"
|
||||
style={{
|
||||
width: '130px',
|
||||
fontSize: '13px',
|
||||
color: '#475569',
|
||||
textAlign: 'left',
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{formatLabel(label)}
|
||||
</div>
|
||||
<div
|
||||
className="bar-wrapper"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bar-bg"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '8px',
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bar-fill"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
backgroundColor: color,
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="bar-value"
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: '#475569',
|
||||
minWidth: '24px',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FrictionPoints({ qualitative, satisfaction, friction }) {
|
||||
const { frictionPoints } = qualitative;
|
||||
if (!frictionPoints) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2
|
||||
id="section-friction"
|
||||
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
|
||||
>
|
||||
Where Things Go Wrong
|
||||
</h2>
|
||||
{frictionPoints.intro && (
|
||||
<p className="section-intro">
|
||||
<MarkdownText>{frictionPoints.intro}</MarkdownText>
|
||||
</p>
|
||||
)}
|
||||
<div className="friction-categories">
|
||||
{Array.isArray(frictionPoints.categories) &&
|
||||
frictionPoints.categories.map((cat, idx) => (
|
||||
<div key={idx} className="friction-category">
|
||||
<div className="friction-title">{cat.category}</div>
|
||||
<div className="friction-desc">
|
||||
<MarkdownText>{cat.description}</MarkdownText>
|
||||
</div>
|
||||
{Array.isArray(cat.examples) && cat.examples.length > 0 && (
|
||||
<ul className="friction-examples">
|
||||
{cat.examples.map((ex, i) => (
|
||||
<li key={i}>
|
||||
<MarkdownText>{ex}</MarkdownText>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Facets Data Charts */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '24px',
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{friction && Object.keys(friction).length > 0 && (
|
||||
<HorizontalBarChart
|
||||
data={friction}
|
||||
title="Primary Friction Types"
|
||||
color="#ef4444"
|
||||
allowedKeys={[
|
||||
'misunderstood_request',
|
||||
'wrong_approach',
|
||||
'buggy_code',
|
||||
'user_rejected_action',
|
||||
'excessive_changes',
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{satisfaction && Object.keys(satisfaction).length > 0 && (
|
||||
<HorizontalBarChart
|
||||
data={satisfaction}
|
||||
title="Inferred Satisfaction (model-estimated)"
|
||||
color="#10b981"
|
||||
allowedKeys={[
|
||||
'happy',
|
||||
'satisfied',
|
||||
'likely_satisfied',
|
||||
'dissatisfied',
|
||||
'frustrated',
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Qwen.md Additions Section Component
|
||||
function QwenMdAdditionsSection({ additions }) {
|
||||
const [checkedState, setCheckedState] = useState(
|
||||
new Array(additions.length).fill(true),
|
||||
);
|
||||
const [copiedAll, setCopiedAll] = useState(false);
|
||||
|
||||
const handleCheckboxChange = (position) => {
|
||||
const updatedCheckedState = checkedState.map((item, index) =>
|
||||
index === position ? !item : item,
|
||||
);
|
||||
setCheckedState(updatedCheckedState);
|
||||
};
|
||||
|
||||
const handleCopyAll = () => {
|
||||
const textToCopy = additions
|
||||
.filter((_, index) => checkedState[index])
|
||||
.map((item) => item.addition)
|
||||
.join('\n\n');
|
||||
|
||||
if (!textToCopy) return;
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
setCopiedAll(true);
|
||||
setTimeout(() => setCopiedAll(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const checkedCount = checkedState.filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="qwen-md-section">
|
||||
<h3>Suggested QWEN.md Additions</h3>
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
Just copy this into Qwen Code to add it to your QWEN.md.
|
||||
</p>
|
||||
|
||||
<div className="qwen-md-actions" style={{ marginBottom: '12px' }}>
|
||||
<button
|
||||
className={`copy-all-btn ${copiedAll ? 'copied' : ''}`}
|
||||
onClick={handleCopyAll}
|
||||
disabled={checkedCount === 0}
|
||||
>
|
||||
{copiedAll ? 'Copied All!' : `Copy All Checked (${checkedCount})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{additions.map((item, idx) => (
|
||||
<div key={idx} className="qwen-md-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedState[idx]}
|
||||
onChange={() => handleCheckboxChange(idx)}
|
||||
className="cmd-checkbox"
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<code className="cmd-code">{item.addition}</code>
|
||||
<div className="cmd-why">
|
||||
<MarkdownText>{item.why}</MarkdownText>
|
||||
</div>
|
||||
</div>
|
||||
<CopyButton text={item.addition} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Improvements({ qualitative }) {
|
||||
const { improvements } = qualitative;
|
||||
if (!improvements) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2
|
||||
id="section-features"
|
||||
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
|
||||
>
|
||||
Existing QC Features to Try
|
||||
</h2>
|
||||
|
||||
{/* QWEN.md Additions */}
|
||||
{Array.isArray(improvements.Qwen_md_additions) &&
|
||||
improvements.Qwen_md_additions.length > 0 && (
|
||||
<QwenMdAdditionsSection additions={improvements.Qwen_md_additions} />
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
Just copy this into Qwen Code and it'll set it up for you.
|
||||
</p>
|
||||
|
||||
{/* Features to Try */}
|
||||
<div className="features-section">
|
||||
{Array.isArray(improvements.features_to_try) &&
|
||||
improvements.features_to_try.map((feat, idx) => (
|
||||
<div key={idx} className="feature-card">
|
||||
<div className="feature-title">{feat.feature}</div>
|
||||
<div className="feature-oneliner">
|
||||
<MarkdownText>{feat.one_liner}</MarkdownText>
|
||||
</div>
|
||||
<div className="feature-why">
|
||||
<strong>Why for you:</strong>{' '}
|
||||
<MarkdownText>{feat.why_for_you}</MarkdownText>
|
||||
</div>
|
||||
<div className="feature-examples">
|
||||
<div className="feature-example">
|
||||
<div className="example-code-row">
|
||||
<code className="example-code">{feat.example_code}</code>
|
||||
<CopyButton text={feat.example_code} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="section-patterns"
|
||||
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
|
||||
>
|
||||
New Ways to Use Qwen Code
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
Just copy this into Qwen Code and it'll walk you through it.
|
||||
</p>
|
||||
|
||||
<div className="patterns-section">
|
||||
{Array.isArray(improvements.usage_patterns) &&
|
||||
improvements.usage_patterns.map((pat, idx) => (
|
||||
<div key={idx} className="pattern-card">
|
||||
<div className="pattern-title">{pat.title}</div>
|
||||
<div className="pattern-summary">
|
||||
<MarkdownText>{pat.suggestion}</MarkdownText>
|
||||
</div>
|
||||
<div className="pattern-detail">
|
||||
<MarkdownText>{pat.detail}</MarkdownText>
|
||||
</div>
|
||||
<div className="copyable-prompt-section">
|
||||
<div className="prompt-label">Paste into Qwen Code:</div>
|
||||
<div className="copyable-prompt-row">
|
||||
<code className="copyable-prompt">{pat.copyable_prompt}</code>
|
||||
<CopyButton text={pat.copyable_prompt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FutureOpportunities({ qualitative }) {
|
||||
const { futureOpportunities } = qualitative;
|
||||
if (!futureOpportunities) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2
|
||||
id="section-horizon"
|
||||
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
|
||||
>
|
||||
On the Horizon
|
||||
</h2>
|
||||
{futureOpportunities.intro && (
|
||||
<p className="section-intro">
|
||||
<MarkdownText>{futureOpportunities.intro}</MarkdownText>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="horizon-section">
|
||||
{Array.isArray(futureOpportunities.opportunities) &&
|
||||
futureOpportunities.opportunities.map((opp, idx) => (
|
||||
<div key={idx} className="horizon-card">
|
||||
<div className="horizon-title">{opp.title}</div>
|
||||
<div className="horizon-possible">
|
||||
<MarkdownText>{opp.whats_possible}</MarkdownText>
|
||||
</div>
|
||||
<div className="horizon-tip">
|
||||
<strong>Getting started:</strong>{' '}
|
||||
<MarkdownText>{opp.how_to_try}</MarkdownText>
|
||||
</div>
|
||||
<div className="pattern-prompt">
|
||||
<div className="prompt-label">Paste into Qwen Code:</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<code style={{ flex: 1 }}>{opp.copyable_prompt}</code>
|
||||
<CopyButton text={opp.copyable_prompt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MemorableMoment({ qualitative }) {
|
||||
const { memorableMoment } = qualitative;
|
||||
if (!memorableMoment) return null;
|
||||
|
||||
return (
|
||||
<div className="fun-ending">
|
||||
<div className="fun-headline">"{memorableMoment.headline}"</div>
|
||||
<div className="fun-detail">
|
||||
<MarkdownText>{memorableMoment.detail}</MarkdownText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const { useState, useRef, useEffect } = React;
|
||||
|
||||
// Simple Markdown Parser Component
|
||||
function MarkdownText({ children }) {
|
||||
if (!children || typeof children !== 'string') return children;
|
||||
|
||||
// Split by bold markers (**text**)
|
||||
const parts = children.split(/(\*\*.*?\*\*)/g);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**') && part.length >= 4) {
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text, label = 'Copy' }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="copy-btn" onClick={handleCopy}>
|
||||
{copied ? 'Copied!' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue