mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 07:54:38 +00:00
feat(export): refactor HTML export components and improve metadata
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
d59e668729
commit
ccecc472dc
11 changed files with 511 additions and 328 deletions
|
|
@ -109,47 +109,46 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats {
|
|||
function calculateTokenStats(
|
||||
records: ChatRecord[],
|
||||
contextWindowSize?: number,
|
||||
): { totalTokens: number; promptTokens: number; contextUsagePercent?: number } {
|
||||
): { totalTokens: number; contextUsagePercent?: number } {
|
||||
let totalTokens = 0;
|
||||
let lastPromptTokens = 0;
|
||||
let lastTotalTokens = 0;
|
||||
|
||||
// Aggregate usageMetadata from all assistant records
|
||||
// Use last available promptTokenCount for context usage calculation
|
||||
// Use last available totalTokenCount for context usage calculation
|
||||
for (const record of records) {
|
||||
if (record.type === 'assistant' && record.usageMetadata) {
|
||||
totalTokens += record.usageMetadata.totalTokenCount ?? 0;
|
||||
// Use the last available promptTokenCount (represents current context usage)
|
||||
if (record.usageMetadata.promptTokenCount !== undefined) {
|
||||
lastPromptTokens = record.usageMetadata.promptTokenCount;
|
||||
// Use the last available totalTokenCount for context usage calculation
|
||||
if (record.usageMetadata.totalTokenCount !== undefined) {
|
||||
lastTotalTokens = record.usageMetadata.totalTokenCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use promptTokens (input tokens) for context usage calculation
|
||||
// This represents how much of the context window is being used
|
||||
if (contextWindowSize && lastPromptTokens > 0) {
|
||||
const percent = (lastPromptTokens / contextWindowSize) * 100;
|
||||
// Use last totalTokenCount for context usage calculation
|
||||
// This represents how much of the context window is being used by the total tokens
|
||||
if (contextWindowSize && lastTotalTokens > 0) {
|
||||
const percent = (lastTotalTokens / contextWindowSize) * 100;
|
||||
return {
|
||||
totalTokens,
|
||||
promptTokens: lastPromptTokens,
|
||||
contextUsagePercent: Math.round(percent * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
return { totalTokens, promptTokens: lastPromptTokens };
|
||||
return { totalTokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract session metadata from ChatRecords.
|
||||
*/
|
||||
function extractMetadata(
|
||||
async function extractMetadata(
|
||||
conversation: {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
},
|
||||
config: Config,
|
||||
): ExportMetadata {
|
||||
): Promise<ExportMetadata> {
|
||||
const { sessionId, startTime, messages } = conversation;
|
||||
|
||||
// Extract basic info from the first record
|
||||
|
|
@ -157,6 +156,13 @@ function extractMetadata(
|
|||
const cwd = firstRecord?.cwd ?? '';
|
||||
const gitBranch = firstRecord?.gitBranch;
|
||||
|
||||
// Get git repository name
|
||||
let gitRepo: string | undefined;
|
||||
if (cwd) {
|
||||
const { getGitRepoName } = await import('@qwen-code/qwen-code-core');
|
||||
gitRepo = getGitRepoName(cwd);
|
||||
}
|
||||
|
||||
// Try to get model from assistant messages
|
||||
let model: string | undefined;
|
||||
for (const record of messages) {
|
||||
|
|
@ -197,11 +203,13 @@ function extractMetadata(
|
|||
startTime,
|
||||
exportTime: new Date().toISOString(),
|
||||
cwd,
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
model,
|
||||
channel,
|
||||
promptCount,
|
||||
contextUsagePercent: tokenStats.contextUsagePercent,
|
||||
contextWindowSize,
|
||||
totalTokens: tokenStats.totalTokens,
|
||||
filesRead: fileStats.filesRead,
|
||||
filesWritten: fileStats.filesWritten,
|
||||
|
|
@ -505,7 +513,7 @@ export async function collectSessionData(
|
|||
const messages = exportContext.getMessages();
|
||||
|
||||
// Extract metadata from conversation
|
||||
const metadata = extractMetadata(conversation, config);
|
||||
const metadata = await extractMetadata(conversation, config);
|
||||
|
||||
return {
|
||||
sessionId: conversation.sessionId,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ export interface ExportMetadata {
|
|||
exportTime: string;
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Git repository name, if available */
|
||||
gitRepo?: string;
|
||||
/** Git branch name, if available */
|
||||
gitBranch?: string;
|
||||
/** Model used in the session */
|
||||
|
|
@ -74,6 +76,8 @@ export interface ExportMetadata {
|
|||
promptCount: number;
|
||||
/** Context window utilization percentage (0-100) */
|
||||
contextUsagePercent?: number;
|
||||
/** Context window size in tokens (used for calculating percentage) */
|
||||
contextWindowSize?: number;
|
||||
/** Total tokens used (prompt + completion) */
|
||||
totalTokens?: number;
|
||||
/** Number of files read */
|
||||
|
|
|
|||
|
|
@ -88,3 +88,61 @@ export const getGitBranch = (cwd: string): string | undefined => {
|
|||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the git repository full name (owner/repo), if in a git repository.
|
||||
* Tries to get the name from the remote URL first, then falls back to the directory name.
|
||||
*/
|
||||
export const getGitRepoName = (cwd: string): string | undefined => {
|
||||
try {
|
||||
// Try to get the repository name from the remote URL
|
||||
const remoteUrl = execSync('git remote get-url origin', {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
|
||||
if (remoteUrl) {
|
||||
// Extract owner/repo from various URL formats:
|
||||
// - https://github.com/owner/repo.git -> owner/repo
|
||||
// - git@github.com:owner/repo.git -> owner/repo
|
||||
// - https://gitlab.com/owner/repo -> owner/repo
|
||||
// - https://github.com/owner/repo/extra -> owner/repo (ignore extra path)
|
||||
|
||||
// Handle SSH format: git@host.com:owner/repo.git
|
||||
let normalizedUrl = remoteUrl;
|
||||
if (remoteUrl.startsWith('git@')) {
|
||||
normalizedUrl = remoteUrl.replace(/^git@[^:]+:/, 'https://host.com/');
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(normalizedUrl);
|
||||
// Remove .git suffix and split path
|
||||
const pathParts = url.pathname
|
||||
.replace(/\.git$/, '')
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
if (pathParts.length >= 2) {
|
||||
// Return owner/repo format
|
||||
return `${pathParts[0]}/${pathParts[1]}`;
|
||||
}
|
||||
} catch {
|
||||
// URL parsing failed, try regex fallback
|
||||
const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
||||
if (match && match[1] && match[2]) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to directory name if remote URL is not available
|
||||
}
|
||||
|
||||
// Fallback: use the directory name of the git root
|
||||
const gitRoot = findGitRoot(cwd);
|
||||
if (gitRoot) {
|
||||
return path.basename(gitRoot);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
const React = window.React;
|
||||
|
||||
export type CopyButtonProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const CopyButton = ({ text }: CopyButtonProps) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="copy-button"
|
||||
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export type MetadataItemProps = {
|
||||
label: string;
|
||||
value?: string | number;
|
||||
valueClass?: string;
|
||||
};
|
||||
|
||||
export const MetadataItem = ({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: MetadataItemProps) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="metadata-item">
|
||||
<div className="metadata-content">
|
||||
<span className="metadata-label">{label}</span>
|
||||
<span
|
||||
className={`metadata-value ${valueClass || ''}`}
|
||||
title={typeof value === 'string' ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import type { ExportMetadata } from './types.js';
|
||||
import { MetadataItem } from './MetadataItem.js';
|
||||
import { CopyButton } from './CopyButton.js';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
formatExportTime,
|
||||
formatPath,
|
||||
formatTokenLimit,
|
||||
} from './utils.js';
|
||||
|
||||
export type MetadataSidebarProps = {
|
||||
metadata: ExportMetadata;
|
||||
};
|
||||
|
||||
export const MetadataSidebar = ({ metadata }: MetadataSidebarProps) => {
|
||||
const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0;
|
||||
|
||||
return (
|
||||
<aside className="metadata-sidebar">
|
||||
<div className="metadata-section">
|
||||
<h3 className="metadata-section-title">Session Info</h3>
|
||||
<MetadataItem
|
||||
label="Session created"
|
||||
value={formatRelativeTime(metadata.startTime)}
|
||||
/>
|
||||
<MetadataItem label="Project" value={formatPath(metadata.cwd)} />
|
||||
{metadata.gitRepo && (
|
||||
<MetadataItem label="Repository" value={metadata.gitRepo} />
|
||||
)}
|
||||
{metadata.gitBranch && (
|
||||
<MetadataItem label="Branch" value={metadata.gitBranch} />
|
||||
)}
|
||||
{metadata.model && (
|
||||
<MetadataItem label="Model" value={metadata.model} />
|
||||
)}
|
||||
{metadata.channel && (
|
||||
<MetadataItem label="Channel" value={metadata.channel} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="metadata-section">
|
||||
<h3 className="metadata-section-title">Statistics</h3>
|
||||
<MetadataItem label="Prompts" value={metadata.promptCount} />
|
||||
{metadata.contextUsagePercent !== undefined && (
|
||||
<MetadataItem
|
||||
label="Context"
|
||||
value={`${metadata.contextUsagePercent}% of ${formatTokenLimit(metadata.contextWindowSize)}`}
|
||||
/>
|
||||
)}
|
||||
{metadata.totalTokens !== undefined && (
|
||||
<MetadataItem
|
||||
label="Tokens"
|
||||
value={metadata.totalTokens.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<MetadataItem label="Files" value={uniqueFilesCount} />
|
||||
</div>
|
||||
|
||||
<div className="metadata-section">
|
||||
<h3 className="metadata-section-title">File Operations</h3>
|
||||
{metadata.filesRead !== undefined && metadata.filesRead > 0 && (
|
||||
<MetadataItem label="Read" value={metadata.filesRead} />
|
||||
)}
|
||||
{metadata.filesWritten !== undefined && metadata.filesWritten > 0 && (
|
||||
<MetadataItem label="Written" value={metadata.filesWritten} />
|
||||
)}
|
||||
{metadata.linesAdded !== undefined && metadata.linesAdded > 0 && (
|
||||
<MetadataItem
|
||||
label="Added"
|
||||
value={`+${metadata.linesAdded}`}
|
||||
valueClass="text-green"
|
||||
/>
|
||||
)}
|
||||
{metadata.linesRemoved !== undefined && metadata.linesRemoved > 0 && (
|
||||
<MetadataItem
|
||||
label="Removed"
|
||||
value={`-${metadata.linesRemoved}`}
|
||||
valueClass="text-red"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="metadata-section metadata-section-small">
|
||||
{metadata.requestId ? (
|
||||
<div className="metadata-item">
|
||||
<div className="metadata-content">
|
||||
<span className="metadata-label">Request Id</span>
|
||||
<div className="metadata-value-with-copy">
|
||||
<span className="metadata-value font-mono">
|
||||
{metadata.requestId}
|
||||
</span>
|
||||
<CopyButton text={metadata.requestId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MetadataItem
|
||||
label="Session ID"
|
||||
value={metadata.sessionId}
|
||||
valueClass="font-mono"
|
||||
/>
|
||||
)}
|
||||
<MetadataItem
|
||||
label="Export Time"
|
||||
value={formatExportTime(metadata.exportTime)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { PlatformContextValue } from './types.js';
|
||||
import { useModalState } from './TempFileModal.js';
|
||||
|
||||
const React = window.React;
|
||||
|
||||
/**
|
||||
* Hook to provide platform context for the export HTML viewer
|
||||
*/
|
||||
export const usePlatformContext = () => {
|
||||
const { modalState, openModal, closeModal } = useModalState();
|
||||
|
||||
const platformContext = React.useMemo(
|
||||
() =>
|
||||
({
|
||||
platform: 'web' as PlatformContextValue['platform'],
|
||||
postMessage: (message: unknown) => {
|
||||
console.log('Posted message:', message);
|
||||
},
|
||||
onMessage: (handler: (event: MessageEvent) => void) => {
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
},
|
||||
openFile: (path: string) => {
|
||||
console.log('Opening file:', path);
|
||||
},
|
||||
openTempFile: openModal,
|
||||
getResourceUrl: () => undefined,
|
||||
features: {
|
||||
canOpenFile: false,
|
||||
canOpenTempFile: true,
|
||||
canCopy: true,
|
||||
},
|
||||
}) satisfies PlatformContextValue,
|
||||
[openModal],
|
||||
);
|
||||
|
||||
return { platformContext, modalState, closeModal };
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Type definitions for export-html
|
||||
*/
|
||||
|
||||
export type ChatData = {
|
||||
messages?: unknown[];
|
||||
sessionId?: string;
|
||||
startTime?: string;
|
||||
metadata?: ExportMetadata;
|
||||
};
|
||||
|
||||
export type ExportMetadata = {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
relativeTime: string;
|
||||
exportTime: string;
|
||||
cwd: string;
|
||||
gitRepo?: string;
|
||||
gitBranch?: string;
|
||||
model?: string;
|
||||
channel?: string;
|
||||
promptCount: number;
|
||||
contextUsagePercent?: number;
|
||||
contextWindowSize?: number;
|
||||
totalTokens?: number;
|
||||
filesRead?: number;
|
||||
filesWritten?: number;
|
||||
linesAdded?: number;
|
||||
linesRemoved?: number;
|
||||
uniqueFiles: string[];
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type PlatformContextValue = {
|
||||
platform: 'web';
|
||||
postMessage: (message: unknown) => void;
|
||||
onMessage: (handler: (event: MessageEvent) => void) => () => void;
|
||||
openFile: (path: string) => void;
|
||||
openTempFile?: (content: string, fileName?: string) => void;
|
||||
getResourceUrl: () => string | undefined;
|
||||
features: {
|
||||
canOpenFile: boolean;
|
||||
canOpenTempFile?: boolean;
|
||||
canCopy: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChatViewerMessage = { type?: string } & Record<string, unknown>;
|
||||
135
packages/web-templates/src/export-html/src/components/utils.ts
Normal file
135
packages/web-templates/src/export-html/src/components/utils.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import type { ChatData, ChatViewerMessage } from './types.js';
|
||||
|
||||
/**
|
||||
* Type guard for ChatViewerMessage
|
||||
*/
|
||||
export const isChatViewerMessage = (
|
||||
value: unknown,
|
||||
): value is ChatViewerMessage => Boolean(value) && typeof value === 'object';
|
||||
|
||||
/**
|
||||
* Parse chat data from the embedded script tag
|
||||
*/
|
||||
export const parseChatData = (): ChatData => {
|
||||
const chatDataElement = document.getElementById('chat-data');
|
||||
if (!chatDataElement?.textContent) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(chatDataElement.textContent) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as ChatData;
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('Failed to parse chat data.', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format session date for display
|
||||
*/
|
||||
export const formatSessionDate = (startTime?: string | null) => {
|
||||
if (!startTime) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(startTime);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return startTime;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format export time for display
|
||||
*/
|
||||
export const formatExportTime = (exportTime?: string | null) => {
|
||||
if (!exportTime) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(exportTime);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return exportTime;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "5 minutes ago")
|
||||
*/
|
||||
export const formatRelativeTime = (startTime?: string | null) => {
|
||||
if (!startTime) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(startTime);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
return 'just now';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
} else if (diffWeeks < 4) {
|
||||
return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
|
||||
} else if (diffMonths < 12) {
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
return `${diffYears} year${diffYears === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format path with truncation
|
||||
*/
|
||||
export const formatPath = (path: string, maxLength: number = 40) => {
|
||||
if (!path || path.length <= maxLength) return path;
|
||||
return '...' + path.slice(-maxLength + 3);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format token limit for display (e.g., 128k, 200k, 1m)
|
||||
*/
|
||||
export const formatTokenLimit = (tokens?: number): string => {
|
||||
if (tokens === undefined || tokens === null) return '128k';
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}m`;
|
||||
}
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(tokens % 1000 === 0 ? 0 : 1)}k`;
|
||||
}
|
||||
return tokens.toString();
|
||||
};
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import './styles.css';
|
||||
import logoSvg from './favicon.svg';
|
||||
import { TempFileModal, useModalState } from './components/TempFileModal';
|
||||
import { TempFileModal } from './components/TempFileModal.js';
|
||||
import { usePlatformContext } from './components/hooks.js';
|
||||
import { MetadataSidebar } from './components/MetadataSidebar.js';
|
||||
import {
|
||||
parseChatData,
|
||||
isChatViewerMessage,
|
||||
formatSessionDate,
|
||||
} from './components/utils.js';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -10,6 +17,7 @@ declare global {
|
|||
}
|
||||
|
||||
const ReactDOM = window.ReactDOM;
|
||||
const React = window.React;
|
||||
|
||||
declare const QwenCodeWebUI: {
|
||||
ChatViewer: (props: {
|
||||
|
|
@ -25,48 +33,6 @@ declare const QwenCodeWebUI: {
|
|||
|
||||
const { ChatViewer, PlatformProvider } = QwenCodeWebUI;
|
||||
|
||||
type ChatData = {
|
||||
messages?: unknown[];
|
||||
sessionId?: string;
|
||||
startTime?: string;
|
||||
metadata?: ExportMetadata;
|
||||
};
|
||||
|
||||
type ExportMetadata = {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
relativeTime: string;
|
||||
exportTime: string;
|
||||
cwd: string;
|
||||
gitBranch?: string;
|
||||
model?: string;
|
||||
channel?: string;
|
||||
promptCount: number;
|
||||
contextUsagePercent?: number;
|
||||
totalTokens?: number;
|
||||
filesRead?: number;
|
||||
filesWritten?: number;
|
||||
linesAdded?: number;
|
||||
linesRemoved?: number;
|
||||
uniqueFiles: string[];
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
type PlatformContextValue = {
|
||||
platform: 'web';
|
||||
postMessage: (message: unknown) => void;
|
||||
onMessage: (handler: (event: MessageEvent) => void) => () => void;
|
||||
openFile: (path: string) => void;
|
||||
openTempFile?: (content: string, fileName?: string) => void;
|
||||
getResourceUrl: () => string | undefined;
|
||||
features: {
|
||||
canOpenFile: boolean;
|
||||
canOpenTempFile?: boolean;
|
||||
canCopy: boolean;
|
||||
};
|
||||
};
|
||||
type ChatViewerMessage = { type?: string } & Record<string, unknown>;
|
||||
|
||||
const logoSvgWithGradient = (() => {
|
||||
if (!logoSvg) {
|
||||
return logoSvg;
|
||||
|
|
@ -80,271 +46,6 @@ const logoSvgWithGradient = (() => {
|
|||
return withDefs.replace(/fill="[^"]*"/, 'fill="url(#qwen-logo-gradient)"');
|
||||
})();
|
||||
|
||||
const React = window.React;
|
||||
|
||||
const usePlatformContext = () => {
|
||||
const { modalState, openModal, closeModal } = useModalState();
|
||||
|
||||
const platformContext = React.useMemo(
|
||||
() =>
|
||||
({
|
||||
platform: 'web' as PlatformContextValue['platform'],
|
||||
postMessage: (message: unknown) => {
|
||||
console.log('Posted message:', message);
|
||||
},
|
||||
onMessage: (handler: (event: MessageEvent) => void) => {
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
},
|
||||
openFile: (path: string) => {
|
||||
console.log('Opening file:', path);
|
||||
},
|
||||
openTempFile: openModal,
|
||||
getResourceUrl: () => undefined,
|
||||
features: {
|
||||
canOpenFile: false,
|
||||
canOpenTempFile: true,
|
||||
canCopy: true,
|
||||
},
|
||||
}) satisfies PlatformContextValue,
|
||||
[openModal],
|
||||
);
|
||||
|
||||
return { platformContext, modalState, closeModal };
|
||||
};
|
||||
|
||||
const isChatViewerMessage = (value: unknown): value is ChatViewerMessage =>
|
||||
Boolean(value) && typeof value === 'object';
|
||||
|
||||
const parseChatData = (): ChatData => {
|
||||
const chatDataElement = document.getElementById('chat-data');
|
||||
if (!chatDataElement?.textContent) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(chatDataElement.textContent) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as ChatData;
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('Failed to parse chat data.', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const formatSessionDate = (startTime?: string | null) => {
|
||||
if (!startTime) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(startTime);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return startTime;
|
||||
}
|
||||
};
|
||||
|
||||
const formatExportTime = (exportTime?: string | null) => {
|
||||
if (!exportTime) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(exportTime);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return exportTime;
|
||||
}
|
||||
};
|
||||
|
||||
const formatPath = (path: string, maxLength: number = 40) => {
|
||||
if (!path || path.length <= maxLength) return path;
|
||||
const parts = path.split('/');
|
||||
if (parts.length <= 2) return '...' + path.slice(-maxLength + 3);
|
||||
return '...' + path.slice(-maxLength + 3);
|
||||
};
|
||||
|
||||
const CopyButton = ({ text }: { text: string }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="copy-button"
|
||||
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const MetadataItem = ({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string | number;
|
||||
valueClass?: string;
|
||||
}) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="metadata-item">
|
||||
<div className="metadata-content">
|
||||
<span className="metadata-label">{label}</span>
|
||||
<span
|
||||
className={`metadata-value ${valueClass || ''}`}
|
||||
title={typeof value === 'string' ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MetadataSidebar = ({ metadata }: { metadata: ExportMetadata }) => {
|
||||
const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0;
|
||||
|
||||
return (
|
||||
<aside className="metadata-sidebar">
|
||||
<div className="metadata-section">
|
||||
<h3 className="metadata-section-title">Session Info</h3>
|
||||
<MetadataItem label="Time" value={metadata.relativeTime} />
|
||||
<MetadataItem label="Project" value={formatPath(metadata.cwd)} />
|
||||
{metadata.gitBranch && (
|
||||
<MetadataItem label="Branch" value={metadata.gitBranch} />
|
||||
)}
|
||||
{metadata.model && (
|
||||
<MetadataItem label="Model" value={metadata.model} />
|
||||
)}
|
||||
{metadata.channel && (
|
||||
<MetadataItem label="Channel" value={metadata.channel} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="metadata-section">
|
||||
<h3 className="metadata-section-title">Statistics</h3>
|
||||
<MetadataItem label="Prompts" value={metadata.promptCount} />
|
||||
{metadata.contextUsagePercent !== undefined && (
|
||||
<MetadataItem
|
||||
label="Context"
|
||||
value={`${metadata.contextUsagePercent}% of 128k`}
|
||||
/>
|
||||
)}
|
||||
{metadata.totalTokens !== undefined && (
|
||||
<MetadataItem
|
||||
label="Tokens"
|
||||
value={metadata.totalTokens.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<MetadataItem label="Files" value={uniqueFilesCount} />
|
||||
</div>
|
||||
|
||||
<div className="metadata-section">
|
||||
<h3 className="metadata-section-title">File Operations</h3>
|
||||
{metadata.filesRead !== undefined && metadata.filesRead > 0 && (
|
||||
<MetadataItem label="Read" value={metadata.filesRead} />
|
||||
)}
|
||||
{metadata.filesWritten !== undefined && metadata.filesWritten > 0 && (
|
||||
<MetadataItem label="Written" value={metadata.filesWritten} />
|
||||
)}
|
||||
{metadata.linesAdded !== undefined && metadata.linesAdded > 0 && (
|
||||
<MetadataItem
|
||||
label="Added"
|
||||
value={`+${metadata.linesAdded}`}
|
||||
valueClass="text-green"
|
||||
/>
|
||||
)}
|
||||
{metadata.linesRemoved !== undefined && metadata.linesRemoved > 0 && (
|
||||
<MetadataItem
|
||||
label="Removed"
|
||||
value={`-${metadata.linesRemoved}`}
|
||||
valueClass="text-red"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="metadata-section metadata-section-small">
|
||||
{metadata.requestId ? (
|
||||
<div className="metadata-item">
|
||||
<div className="metadata-content">
|
||||
<span className="metadata-label">Request Id</span>
|
||||
<div className="metadata-value-with-copy">
|
||||
<span className="metadata-value font-mono">
|
||||
{metadata.requestId}
|
||||
</span>
|
||||
<CopyButton text={metadata.requestId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MetadataItem
|
||||
label="Session ID"
|
||||
value={metadata.sessionId}
|
||||
valueClass="font-mono"
|
||||
/>
|
||||
)}
|
||||
<MetadataItem
|
||||
label="Export Time"
|
||||
value={formatExportTime(metadata.exportTime)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const chatData = parseChatData();
|
||||
const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : [];
|
||||
|
|
|
|||
|
|
@ -212,8 +212,8 @@ body {
|
|||
|
||||
/* Metadata Sidebar - fixed on right */
|
||||
.metadata-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
|
|
@ -267,7 +267,7 @@ body {
|
|||
}
|
||||
|
||||
.metadata-content .metadata-value {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
line-height: 1.3;
|
||||
|
|
@ -320,8 +320,8 @@ body {
|
|||
/* Responsive adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
.metadata-sidebar {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue