diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index ca297200b..c4de5ee75 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -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 { 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, diff --git a/packages/cli/src/ui/utils/export/types.ts b/packages/cli/src/ui/utils/export/types.ts index 3ff0a7352..e73e0fefa 100644 --- a/packages/cli/src/ui/utils/export/types.ts +++ b/packages/cli/src/ui/utils/export/types.ts @@ -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 */ diff --git a/packages/core/src/utils/gitUtils.ts b/packages/core/src/utils/gitUtils.ts index e63b6bebd..493c89bd6 100644 --- a/packages/core/src/utils/gitUtils.ts +++ b/packages/core/src/utils/gitUtils.ts @@ -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; +}; diff --git a/packages/web-templates/src/export-html/src/components/CopyButton.tsx b/packages/web-templates/src/export-html/src/components/CopyButton.tsx new file mode 100644 index 000000000..4a390d50b --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/CopyButton.tsx @@ -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 ( + + ); +}; diff --git a/packages/web-templates/src/export-html/src/components/MetadataItem.tsx b/packages/web-templates/src/export-html/src/components/MetadataItem.tsx new file mode 100644 index 000000000..476ab7fe3 --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/MetadataItem.tsx @@ -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 ( +
+
+ {label} + + {value} + +
+
+ ); +}; diff --git a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx new file mode 100644 index 000000000..7593f6d0e --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx @@ -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 ( + + ); +}; diff --git a/packages/web-templates/src/export-html/src/components/hooks.ts b/packages/web-templates/src/export-html/src/components/hooks.ts new file mode 100644 index 000000000..f4dcd7be0 --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/hooks.ts @@ -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 }; +}; diff --git a/packages/web-templates/src/export-html/src/components/types.ts b/packages/web-templates/src/export-html/src/components/types.ts new file mode 100644 index 000000000..94069c607 --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/types.ts @@ -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; diff --git a/packages/web-templates/src/export-html/src/components/utils.ts b/packages/web-templates/src/export-html/src/components/utils.ts new file mode 100644 index 000000000..a72fa369b --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/utils.ts @@ -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(); +}; diff --git a/packages/web-templates/src/export-html/src/main.tsx b/packages/web-templates/src/export-html/src/main.tsx index 874894903..f9031fc62 100644 --- a/packages/web-templates/src/export-html/src/main.tsx +++ b/packages/web-templates/src/export-html/src/main.tsx @@ -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; - 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 ( - - ); -}; - -const MetadataItem = ({ - label, - value, - valueClass, -}: { - label: string; - value?: string | number; - valueClass?: string; -}) => { - if (value === undefined || value === null || value === '') { - return null; - } - return ( -
-
- {label} - - {value} - -
-
- ); -}; - -const MetadataSidebar = ({ metadata }: { metadata: ExportMetadata }) => { - const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0; - - return ( - - ); -}; - const App = () => { const chatData = parseChatData(); const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : []; diff --git a/packages/web-templates/src/export-html/src/styles.css b/packages/web-templates/src/export-html/src/styles.css index eff5bc2c8..f161b5392 100644 --- a/packages/web-templates/src/export-html/src/styles.css +++ b/packages/web-templates/src/export-html/src/styles.css @@ -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; } }