diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 89b742fc2..8e2237766 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -18,6 +18,7 @@ import { copyCommand } from '../ui/commands/copyCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; +import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; @@ -67,6 +68,7 @@ export class BuiltinCommandLoader implements ICommandLoader { docsCommand, directoryCommand, editorCommand, + exportCommand, extensionsCommand, helpCommand, await ideCommand(), diff --git a/packages/cli/src/ui/commands/exportCommand.test.ts b/packages/cli/src/ui/commands/exportCommand.test.ts new file mode 100644 index 000000000..9930a00da --- /dev/null +++ b/packages/cli/src/ui/commands/exportCommand.test.ts @@ -0,0 +1,379 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import { exportCommand } from './exportCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { ChatRecord } from '@qwen-code/qwen-code-core'; +import type { Part, Content } from '@google/genai'; +import { + transformToMarkdown, + loadHtmlTemplate, + prepareExportData, + injectDataIntoHtmlTemplate, + generateExportFilename, +} from '../utils/exportUtils.js'; + +const mockSessionServiceMocks = vi.hoisted(() => ({ + loadLastSession: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + async loadLastSession() { + return mockSessionServiceMocks.loadLastSession(); + } + } + + return { + SessionService, + }; +}); + +vi.mock('../utils/exportUtils.js', () => ({ + transformToMarkdown: vi.fn(), + loadHtmlTemplate: vi.fn(), + prepareExportData: vi.fn(), + injectDataIntoHtmlTemplate: vi.fn(), + generateExportFilename: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), +})); + +describe('exportCommand', () => { + const mockSessionData = { + conversation: { + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [ + { + type: 'user', + message: { + parts: [{ text: 'Hello' }] as Part[], + } as Content, + }, + ] as ChatRecord[], + }, + }; + + let mockContext: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData); + + mockContext = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue('/test/dir'), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + + vi.mocked(transformToMarkdown).mockReturnValue('# Test Markdown'); + vi.mocked(loadHtmlTemplate).mockResolvedValue( + '', + ); + vi.mocked(prepareExportData).mockReturnValue({ + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: mockSessionData.conversation.messages, + }); + vi.mocked(injectDataIntoHtmlTemplate).mockReturnValue( + '', + ); + vi.mocked(generateExportFilename).mockImplementation( + (ext: string) => `export-2025-01-01T00-00-00-000Z.${ext}`, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('command structure', () => { + it('should have correct name and description', () => { + expect(exportCommand.name).toBe('export'); + expect(exportCommand.description).toBe( + 'Export current session message history to a file', + ); + }); + + it('should have md and html subcommands', () => { + expect(exportCommand.subCommands).toHaveLength(2); + expect(exportCommand.subCommands?.map((c) => c.name)).toEqual([ + 'md', + 'html', + ]); + }); + }); + + describe('exportMarkdownAction', () => { + it('should export session to markdown file', async () => { + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), + }); + + expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(transformToMarkdown).toHaveBeenCalledWith( + mockSessionData.conversation.messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + expect(generateExportFilename).toHaveBeenCalledWith('md'); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), + '# Test Markdown', + 'utf-8', + ); + }); + + it('should return error when config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); + + it('should return error when working directory cannot be determined', async () => { + const contextWithoutCwd = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue(null), + }, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand || !mdCommand.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(contextWithoutCwd, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }); + }); + + it('should return error when no session is found', async () => { + mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }); + }); + + it('should handle errors during export', async () => { + const error = new Error('File write failed'); + vi.mocked(fs.writeFile).mockRejectedValue(error); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: File write failed', + }); + }); + + it('should use project root when working dir is not available', async () => { + const contextWithProjectRoot = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + await mdCommand.action(contextWithProjectRoot, ''); + }); + }); + + describe('exportHtmlAction', () => { + it('should export session to HTML file', async () => { + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'export-2025-01-01T00-00-00-000Z.html', + ), + }); + + expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(loadHtmlTemplate).toHaveBeenCalled(); + expect(prepareExportData).toHaveBeenCalledWith( + mockSessionData.conversation, + ); + expect(injectDataIntoHtmlTemplate).toHaveBeenCalled(); + expect(generateExportFilename).toHaveBeenCalledWith('html'); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('export-2025-01-01T00-00-00-000Z.html'), + expect.stringContaining('{"data": "test"}'), + 'utf-8', + ); + }); + + it('should return error when config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); + + it('should return error when working directory cannot be determined', async () => { + const contextWithoutCwd = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue(null), + }, + }, + }); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand || !htmlCommand.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(contextWithoutCwd, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }); + }); + + it('should return error when no session is found', async () => { + mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }); + }); + + it('should handle errors during HTML template loading', async () => { + const error = new Error('Failed to fetch template'); + vi.mocked(loadHtmlTemplate).mockRejectedValue(error); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: Failed to fetch template', + }); + }); + + it('should handle errors during file write', async () => { + const error = new Error('File write failed'); + vi.mocked(fs.writeFile).mockRejectedValue(error); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: File write failed', + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts new file mode 100644 index 000000000..88d5289c3 --- /dev/null +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { + type CommandContext, + type SlashCommand, + type MessageActionReturn, + CommandKind, +} from './types.js'; +import { SessionService } from '@qwen-code/qwen-code-core'; +import { + transformToMarkdown, + loadHtmlTemplate, + prepareExportData, + injectDataIntoHtmlTemplate, + generateExportFilename, +} from '../utils/exportUtils.js'; + +/** + * Action for the 'md' subcommand - exports session to markdown. + */ +async function exportMarkdownAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + const markdown = transformToMarkdown( + conversation.messages, + conversation.sessionId, + conversation.startTime, + ); + + const filename = generateExportFilename('md'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, markdown, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to markdown: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Action for the 'html' subcommand - exports session to HTML. + */ +async function exportHtmlAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + const template = await loadHtmlTemplate(); + const exportData = prepareExportData(conversation); + const html = injectDataIntoHtmlTemplate(template, exportData); + + const filename = generateExportFilename('html'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, html, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to HTML: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Main export command with subcommands. + */ +export const exportCommand: SlashCommand = { + name: 'export', + description: 'Export current session message history to a file', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'md', + description: 'Export session to markdown format', + kind: CommandKind.BUILT_IN, + action: exportMarkdownAction, + }, + { + name: 'html', + description: 'Export session to HTML format', + kind: CommandKind.BUILT_IN, + action: exportHtmlAction, + }, + ], +}; diff --git a/packages/cli/src/ui/utils/exportUtils.test.ts b/packages/cli/src/ui/utils/exportUtils.test.ts new file mode 100644 index 000000000..8a8fcb046 --- /dev/null +++ b/packages/cli/src/ui/utils/exportUtils.test.ts @@ -0,0 +1,404 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + extractTextFromContent, + transformToMarkdown, + loadHtmlTemplate, + prepareExportData, + injectDataIntoHtmlTemplate, + generateExportFilename, +} from './exportUtils.js'; +import type { ChatRecord } from '@qwen-code/qwen-code-core'; +import type { Part, Content } from '@google/genai'; + +describe('exportUtils', () => { + describe('extractTextFromContent', () => { + it('should return empty string for undefined content', () => { + expect(extractTextFromContent(undefined)).toBe(''); + }); + + it('should return empty string for content without parts', () => { + expect(extractTextFromContent({} as Content)).toBe(''); + }); + + it('should extract text from text parts', () => { + const content: Content = { + parts: [{ text: 'Hello' }, { text: 'World' }] as Part[], + }; + expect(extractTextFromContent(content)).toBe('Hello\nWorld'); + }); + + it('should format function call parts', () => { + const content: Content = { + parts: [ + { + functionCall: { + name: 'testFunction', + args: { param1: 'value1' }, + }, + }, + ] as Part[], + }; + const result = extractTextFromContent(content); + expect(result).toContain('[Function Call: testFunction]'); + expect(result).toContain('"param1": "value1"'); + }); + + it('should format function response parts', () => { + const content: Content = { + parts: [ + { + functionResponse: { + name: 'testFunction', + response: { result: 'success' }, + }, + }, + ] as Part[], + }; + const result = extractTextFromContent(content); + expect(result).toContain('[Function Response: testFunction]'); + expect(result).toContain('"result": "success"'); + }); + + it('should handle mixed part types', () => { + const content: Content = { + parts: [ + { text: 'Start' }, + { + functionCall: { + name: 'call', + args: {}, + }, + }, + { text: 'End' }, + ] as Part[], + }; + const result = extractTextFromContent(content); + expect(result).toContain('Start'); + expect(result).toContain('[Function Call: call]'); + expect(result).toContain('End'); + }); + }); + + describe('transformToMarkdown', () => { + const mockMessages: ChatRecord[] = [ + { + uuid: 'uuid-1', + parentUuid: null, + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:00Z', + type: 'user', + cwd: '/test', + version: '1.0.0', + message: { + parts: [{ text: 'Hello, how are you?' }] as Part[], + } as Content, + }, + { + uuid: 'uuid-2', + parentUuid: 'uuid-1', + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:01Z', + type: 'assistant', + cwd: '/test', + version: '1.0.0', + message: { + parts: [{ text: 'I am doing well, thank you!' }] as Part[], + } as Content, + }, + ]; + + it('should transform messages to markdown format', () => { + const result = transformToMarkdown( + mockMessages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).toContain('# Chat Session Export'); + expect(result).toContain('**Session ID**: test-session-id'); + expect(result).toContain('**Start Time**: 2025-01-01T00:00:00Z'); + expect(result).toContain('## User'); + expect(result).toContain('Hello, how are you?'); + expect(result).toContain('## Assistant'); + expect(result).toContain('I am doing well, thank you!'); + }); + + it('should include exported timestamp', () => { + const before = new Date().toISOString(); + const result = transformToMarkdown( + mockMessages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + const after = new Date().toISOString(); + + expect(result).toContain('**Exported**:'); + const exportedMatch = result.match(/\*\*Exported\*\*: (.+)/); + expect(exportedMatch).toBeTruthy(); + if (exportedMatch) { + const exportedTime = exportedMatch[1].trim(); + expect(exportedTime >= before).toBe(true); + expect(exportedTime <= after).toBe(true); + } + }); + + it('should format tool_result messages', () => { + const messages: ChatRecord[] = [ + { + uuid: 'uuid-3', + parentUuid: 'uuid-2', + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:02Z', + type: 'tool_result', + cwd: '/test', + version: '1.0.0', + toolCallResult: { + resultDisplay: 'Tool output', + }, + message: { + parts: [{ text: 'Additional info' }] as Part[], + } as Content, + }, + ]; + + const result = transformToMarkdown( + messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).toContain('## Tool Result'); + expect(result).toContain('```'); + expect(result).toContain('Tool output'); + expect(result).toContain('Additional info'); + }); + + it('should format tool_result with JSON resultDisplay', () => { + const messages: ChatRecord[] = [ + { + uuid: 'uuid-4', + parentUuid: 'uuid-3', + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:03Z', + type: 'tool_result', + cwd: '/test', + version: '1.0.0', + toolCallResult: { + resultDisplay: '{"key": "value"}', + }, + message: {} as Content, + }, + ]; + + const result = transformToMarkdown( + messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).toContain('## Tool Result'); + expect(result).toContain('```'); + expect(result).toContain('"key": "value"'); + }); + + it('should handle chat compression system messages', () => { + const messages: ChatRecord[] = [ + { + uuid: 'uuid-5', + parentUuid: null, + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:04Z', + type: 'system', + subtype: 'chat_compression', + cwd: '/test', + version: '1.0.0', + message: {} as Content, + }, + ]; + + const result = transformToMarkdown( + messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).toContain('_[Chat history compressed]_'); + }); + + it('should skip system messages without subtype', () => { + const messages: ChatRecord[] = [ + { + uuid: 'uuid-6', + parentUuid: null, + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:05Z', + type: 'system', + cwd: '/test', + version: '1.0.0', + message: {} as Content, + }, + ]; + + const result = transformToMarkdown( + messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).not.toContain('## System'); + }); + }); + + describe('loadHtmlTemplate', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should load HTML template from URL', async () => { + const mockTemplate = 'Test Template'; + const mockResponse = { + ok: true, + text: vi.fn().mockResolvedValue(mockTemplate), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + const result = await loadHtmlTemplate(); + + expect(result).toBe(mockTemplate); + expect(fetch).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html', + ); + }); + + it('should throw error when fetch fails', async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: 'Not Found', + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await expect(loadHtmlTemplate()).rejects.toThrow( + 'Failed to fetch HTML template: 404 Not Found', + ); + }); + + it('should throw error when network request fails', async () => { + const networkError = new Error('Network error'); + vi.mocked(fetch).mockRejectedValue(networkError); + + await expect(loadHtmlTemplate()).rejects.toThrow( + 'Failed to load HTML template', + ); + await expect(loadHtmlTemplate()).rejects.toThrow('Network error'); + }); + }); + + describe('prepareExportData', () => { + it('should prepare export data from conversation', () => { + const conversation = { + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [ + { + type: 'user', + message: { + parts: [{ text: 'Hello' }] as Part[], + } as Content, + }, + ] as ChatRecord[], + }; + + const result = prepareExportData(conversation); + + expect(result).toEqual({ + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: conversation.messages, + }); + }); + }); + + describe('injectDataIntoHtmlTemplate', () => { + it('should inject JSON data into HTML template', () => { + const template = ` + + + + + + `; + + const data = { + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [] as ChatRecord[], + }; + + const result = injectDataIntoHtmlTemplate(template, data); + + expect(result).toContain( + '`; + + const data = { + sessionId: 'test', + startTime: '2025-01-01T00:00:00Z', + messages: [] as ChatRecord[], + }; + + const result = injectDataIntoHtmlTemplate(template, data); + + expect(result).toContain('"sessionId": "test"'); + expect(result).not.toContain('DATA_PLACEHOLDER'); + }); + }); + + describe('generateExportFilename', () => { + it('should generate filename with timestamp and extension', () => { + const filename = generateExportFilename('md'); + + expect(filename).toMatch( + /^export-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/, + ); + }); + + it('should use provided extension', () => { + const filename1 = generateExportFilename('html'); + const filename2 = generateExportFilename('json'); + + expect(filename1).toMatch(/\.html$/); + expect(filename2).toMatch(/\.json$/); + }); + + it('should replace colons and dots in timestamp', () => { + const filename = generateExportFilename('md'); + + expect(filename).not.toContain(':'); + // The filename should contain a dot only for the extension + expect(filename.split('.').length).toBe(2); + // Check that timestamp part (before extension) doesn't contain dots + const timestampPart = filename.split('.')[0]; + expect(timestampPart).not.toContain('.'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/exportUtils.ts b/packages/cli/src/ui/utils/exportUtils.ts new file mode 100644 index 000000000..165e55996 --- /dev/null +++ b/packages/cli/src/ui/utils/exportUtils.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part, Content } from '@google/genai'; +import type { ChatRecord } from '@qwen-code/qwen-code-core'; + +const HTML_TEMPLATE_URL = + 'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html'; + +/** + * Extracts text content from a Content object's parts. + */ +export function extractTextFromContent(content: Content | undefined): string { + if (!content?.parts) return ''; + + const textParts: string[] = []; + for (const part of content.parts as Part[]) { + if ('text' in part) { + const textPart = part as { text: string }; + textParts.push(textPart.text); + } else if ('functionCall' in part) { + const fnPart = part as { functionCall: { name: string; args: unknown } }; + textParts.push( + `[Function Call: ${fnPart.functionCall.name}]\n${JSON.stringify(fnPart.functionCall.args, null, 2)}`, + ); + } else if ('functionResponse' in part) { + const fnResPart = part as { + functionResponse: { name: string; response: unknown }; + }; + textParts.push( + `[Function Response: ${fnResPart.functionResponse.name}]\n${JSON.stringify(fnResPart.functionResponse.response, null, 2)}`, + ); + } + } + + return textParts.join('\n'); +} + +/** + * Transforms ChatRecord messages to markdown format. + */ +export function transformToMarkdown( + messages: ChatRecord[], + sessionId: string, + startTime: string, +): string { + const lines: string[] = []; + + // Add header with metadata + lines.push('# Chat Session Export\n'); + lines.push(`**Session ID**: ${sessionId}\n`); + lines.push(`**Start Time**: ${startTime}\n`); + lines.push(`**Exported**: ${new Date().toISOString()}\n`); + lines.push('---\n'); + + // Process each message + for (const record of messages) { + if (record.type === 'user') { + lines.push('## User\n'); + const text = extractTextFromContent(record.message); + lines.push(`${text}\n`); + } else if (record.type === 'assistant') { + lines.push('## Assistant\n'); + const text = extractTextFromContent(record.message); + lines.push(`${text}\n`); + } else if (record.type === 'tool_result') { + lines.push('## Tool Result\n'); + if (record.toolCallResult) { + const resultDisplay = record.toolCallResult.resultDisplay; + if (resultDisplay) { + lines.push('```\n'); + lines.push( + typeof resultDisplay === 'string' + ? resultDisplay + : JSON.stringify(resultDisplay, null, 2), + ); + lines.push('\n```\n'); + } + } + const text = extractTextFromContent(record.message); + if (text) { + lines.push(`${text}\n`); + } + } else if (record.type === 'system') { + // Skip system messages or format them minimally + if (record.subtype === 'chat_compression') { + lines.push('_[Chat history compressed]_\n'); + } + } + + lines.push('\n'); + } + + return lines.join(''); +} + +/** + * Loads the HTML template from a remote URL via fetch. + * Throws an error if the fetch fails. + */ +export async function loadHtmlTemplate(): Promise { + try { + const response = await fetch(HTML_TEMPLATE_URL); + if (!response.ok) { + throw new Error( + `Failed to fetch HTML template: ${response.status} ${response.statusText}`, + ); + } + const template = await response.text(); + return template; + } catch (error) { + throw new Error( + `Failed to load HTML template from ${HTML_TEMPLATE_URL}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +/** + * Prepares export data from conversation. + */ +export function prepareExportData(conversation: { + sessionId: string; + startTime: string; + messages: ChatRecord[]; +}): { + sessionId: string; + startTime: string; + messages: ChatRecord[]; +} { + return { + sessionId: conversation.sessionId, + startTime: conversation.startTime, + messages: conversation.messages, + }; +} + +/** + * Injects JSON data into the HTML template. + */ +export function injectDataIntoHtmlTemplate( + template: string, + data: { + sessionId: string; + startTime: string; + messages: ChatRecord[]; + }, +): string { + const jsonData = JSON.stringify(data, null, 2); + const html = template.replace( + /`, + ); + return html; +} + +/** + * Generates a filename with timestamp for export files. + */ +export function generateExportFilename(extension: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `export-${timestamp}.${extension}`; +}