mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 05:31:02 +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(
|
function calculateTokenStats(
|
||||||
records: ChatRecord[],
|
records: ChatRecord[],
|
||||||
contextWindowSize?: number,
|
contextWindowSize?: number,
|
||||||
): { totalTokens: number; promptTokens: number; contextUsagePercent?: number } {
|
): { totalTokens: number; contextUsagePercent?: number } {
|
||||||
let totalTokens = 0;
|
let totalTokens = 0;
|
||||||
let lastPromptTokens = 0;
|
let lastTotalTokens = 0;
|
||||||
|
|
||||||
// Aggregate usageMetadata from all assistant records
|
// 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) {
|
for (const record of records) {
|
||||||
if (record.type === 'assistant' && record.usageMetadata) {
|
if (record.type === 'assistant' && record.usageMetadata) {
|
||||||
totalTokens += record.usageMetadata.totalTokenCount ?? 0;
|
totalTokens += record.usageMetadata.totalTokenCount ?? 0;
|
||||||
// Use the last available promptTokenCount (represents current context usage)
|
// Use the last available totalTokenCount for context usage calculation
|
||||||
if (record.usageMetadata.promptTokenCount !== undefined) {
|
if (record.usageMetadata.totalTokenCount !== undefined) {
|
||||||
lastPromptTokens = record.usageMetadata.promptTokenCount;
|
lastTotalTokens = record.usageMetadata.totalTokenCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use promptTokens (input tokens) for context usage calculation
|
// Use last totalTokenCount for context usage calculation
|
||||||
// This represents how much of the context window is being used
|
// This represents how much of the context window is being used by the total tokens
|
||||||
if (contextWindowSize && lastPromptTokens > 0) {
|
if (contextWindowSize && lastTotalTokens > 0) {
|
||||||
const percent = (lastPromptTokens / contextWindowSize) * 100;
|
const percent = (lastTotalTokens / contextWindowSize) * 100;
|
||||||
return {
|
return {
|
||||||
totalTokens,
|
totalTokens,
|
||||||
promptTokens: lastPromptTokens,
|
|
||||||
contextUsagePercent: Math.round(percent * 10) / 10,
|
contextUsagePercent: Math.round(percent * 10) / 10,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { totalTokens, promptTokens: lastPromptTokens };
|
return { totalTokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract session metadata from ChatRecords.
|
* Extract session metadata from ChatRecords.
|
||||||
*/
|
*/
|
||||||
function extractMetadata(
|
async function extractMetadata(
|
||||||
conversation: {
|
conversation: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
messages: ChatRecord[];
|
messages: ChatRecord[];
|
||||||
},
|
},
|
||||||
config: Config,
|
config: Config,
|
||||||
): ExportMetadata {
|
): Promise<ExportMetadata> {
|
||||||
const { sessionId, startTime, messages } = conversation;
|
const { sessionId, startTime, messages } = conversation;
|
||||||
|
|
||||||
// Extract basic info from the first record
|
// Extract basic info from the first record
|
||||||
|
|
@ -157,6 +156,13 @@ function extractMetadata(
|
||||||
const cwd = firstRecord?.cwd ?? '';
|
const cwd = firstRecord?.cwd ?? '';
|
||||||
const gitBranch = firstRecord?.gitBranch;
|
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
|
// Try to get model from assistant messages
|
||||||
let model: string | undefined;
|
let model: string | undefined;
|
||||||
for (const record of messages) {
|
for (const record of messages) {
|
||||||
|
|
@ -197,11 +203,13 @@ function extractMetadata(
|
||||||
startTime,
|
startTime,
|
||||||
exportTime: new Date().toISOString(),
|
exportTime: new Date().toISOString(),
|
||||||
cwd,
|
cwd,
|
||||||
|
gitRepo,
|
||||||
gitBranch,
|
gitBranch,
|
||||||
model,
|
model,
|
||||||
channel,
|
channel,
|
||||||
promptCount,
|
promptCount,
|
||||||
contextUsagePercent: tokenStats.contextUsagePercent,
|
contextUsagePercent: tokenStats.contextUsagePercent,
|
||||||
|
contextWindowSize,
|
||||||
totalTokens: tokenStats.totalTokens,
|
totalTokens: tokenStats.totalTokens,
|
||||||
filesRead: fileStats.filesRead,
|
filesRead: fileStats.filesRead,
|
||||||
filesWritten: fileStats.filesWritten,
|
filesWritten: fileStats.filesWritten,
|
||||||
|
|
@ -505,7 +513,7 @@ export async function collectSessionData(
|
||||||
const messages = exportContext.getMessages();
|
const messages = exportContext.getMessages();
|
||||||
|
|
||||||
// Extract metadata from conversation
|
// Extract metadata from conversation
|
||||||
const metadata = extractMetadata(conversation, config);
|
const metadata = await extractMetadata(conversation, config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId: conversation.sessionId,
|
sessionId: conversation.sessionId,
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ export interface ExportMetadata {
|
||||||
exportTime: string;
|
exportTime: string;
|
||||||
/** Current working directory */
|
/** Current working directory */
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
/** Git repository name, if available */
|
||||||
|
gitRepo?: string;
|
||||||
/** Git branch name, if available */
|
/** Git branch name, if available */
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
/** Model used in the session */
|
/** Model used in the session */
|
||||||
|
|
@ -74,6 +76,8 @@ export interface ExportMetadata {
|
||||||
promptCount: number;
|
promptCount: number;
|
||||||
/** Context window utilization percentage (0-100) */
|
/** Context window utilization percentage (0-100) */
|
||||||
contextUsagePercent?: number;
|
contextUsagePercent?: number;
|
||||||
|
/** Context window size in tokens (used for calculating percentage) */
|
||||||
|
contextWindowSize?: number;
|
||||||
/** Total tokens used (prompt + completion) */
|
/** Total tokens used (prompt + completion) */
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
/** Number of files read */
|
/** Number of files read */
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,61 @@ export const getGitBranch = (cwd: string): string | undefined => {
|
||||||
return 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 './styles.css';
|
||||||
import logoSvg from './favicon.svg';
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -10,6 +17,7 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactDOM = window.ReactDOM;
|
const ReactDOM = window.ReactDOM;
|
||||||
|
const React = window.React;
|
||||||
|
|
||||||
declare const QwenCodeWebUI: {
|
declare const QwenCodeWebUI: {
|
||||||
ChatViewer: (props: {
|
ChatViewer: (props: {
|
||||||
|
|
@ -25,48 +33,6 @@ declare const QwenCodeWebUI: {
|
||||||
|
|
||||||
const { ChatViewer, PlatformProvider } = 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 = (() => {
|
const logoSvgWithGradient = (() => {
|
||||||
if (!logoSvg) {
|
if (!logoSvg) {
|
||||||
return logoSvg;
|
return logoSvg;
|
||||||
|
|
@ -80,271 +46,6 @@ const logoSvgWithGradient = (() => {
|
||||||
return withDefs.replace(/fill="[^"]*"/, 'fill="url(#qwen-logo-gradient)"');
|
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 App = () => {
|
||||||
const chatData = parseChatData();
|
const chatData = parseChatData();
|
||||||
const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : [];
|
const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : [];
|
||||||
|
|
|
||||||
|
|
@ -212,8 +212,8 @@ body {
|
||||||
|
|
||||||
/* Metadata Sidebar - fixed on right */
|
/* Metadata Sidebar - fixed on right */
|
||||||
.metadata-sidebar {
|
.metadata-sidebar {
|
||||||
width: 280px;
|
width: 320px;
|
||||||
min-width: 280px;
|
min-width: 320px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
|
|
@ -267,7 +267,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-content .metadata-value {
|
.metadata-content .metadata-value {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
|
@ -320,8 +320,8 @@ body {
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.metadata-sidebar {
|
.metadata-sidebar {
|
||||||
width: 260px;
|
width: 320px;
|
||||||
min-width: 260px;
|
min-width: 320px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue