Compare commits

..

1 Commits

Author SHA1 Message Date
mingholy.lmh
9457870c93 feat: add export command draft for session history with markdown and HTML formats 2026-01-16 17:16:02 +08:00
8 changed files with 1156 additions and 27 deletions

View File

@@ -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(),

View File

@@ -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<typeof createMockCommandContext>;
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(
'<html><script id="chat-data" type="application/json">// DATA_PLACEHOLDER</script></html>',
);
vi.mocked(prepareExportData).mockReturnValue({
sessionId: 'test-session-id',
startTime: '2025-01-01T00:00:00Z',
messages: mockSessionData.conversation.messages,
});
vi.mocked(injectDataIntoHtmlTemplate).mockReturnValue(
'<html><script id="chat-data" type="application/json">{"data": "test"}</script></html>',
);
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',
});
});
});
});

View File

@@ -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<MessageActionReturn> {
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<MessageActionReturn> {
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,
},
],
};

View File

@@ -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 = '<html><body>Test Template</body></html>';
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 = `
<html>
<body>
<script id="chat-data" type="application/json">
// DATA_PLACEHOLDER: Your JSONL data will be injected here
</script>
</body>
</html>
`;
const data = {
sessionId: 'test-session-id',
startTime: '2025-01-01T00:00:00Z',
messages: [] as ChatRecord[],
};
const result = injectDataIntoHtmlTemplate(template, data);
expect(result).toContain(
'<script id="chat-data" type="application/json">',
);
expect(result).toContain('"sessionId": "test-session-id"');
expect(result).toContain('"startTime": "2025-01-01T00:00:00Z"');
expect(result).not.toContain('DATA_PLACEHOLDER');
});
it('should handle template with whitespace around placeholder', () => {
const template = `<script id="chat-data" type="application/json">\n// DATA_PLACEHOLDER: Your JSONL data will be injected here\n</script>`;
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('.');
});
});
});

View File

@@ -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<string> {
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(
/<script id="chat-data" type="application\/json">\s*\/\/ DATA_PLACEHOLDER:.*?\s*<\/script>/s,
`<script id="chat-data" type="application/json">\n${jsonData}\n </script>`,
);
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}`;
}

View File

@@ -404,7 +404,7 @@ export class Config {
private toolRegistry!: ToolRegistry;
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
private skillManager: SkillManager | null = null;
private skillManager!: SkillManager;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
@@ -672,10 +672,8 @@ export class Config {
}
this.promptRegistry = new PromptRegistry();
this.subagentManager = new SubagentManager(this);
if (this.getExperimentalSkills()) {
this.skillManager = new SkillManager(this);
await this.skillManager.startWatching();
}
this.skillManager = new SkillManager(this);
await this.skillManager.startWatching();
// Load session subagents if they were provided before initialization
if (this.sessionSubagents.length > 0) {
@@ -1441,7 +1439,7 @@ export class Config {
return this.subagentManager;
}
getSkillManager(): SkillManager | null {
getSkillManager(): SkillManager {
return this.skillManager;
}

View File

@@ -235,7 +235,6 @@ export class SkillManager {
}
this.watchStarted = true;
await this.ensureUserSkillsDir();
await this.refreshCache();
this.updateWatchersFromCache();
}
@@ -487,14 +486,29 @@ export class SkillManager {
}
private updateWatchersFromCache(): void {
const watchTargets = new Set<string>(
(['project', 'user'] as const)
.map((level) => this.getSkillsBaseDir(level))
.filter((baseDir) => fsSync.existsSync(baseDir)),
);
const desiredPaths = new Set<string>();
for (const level of ['project', 'user'] as const) {
const baseDir = this.getSkillsBaseDir(level);
const parentDir = path.dirname(baseDir);
if (fsSync.existsSync(parentDir)) {
desiredPaths.add(parentDir);
}
if (fsSync.existsSync(baseDir)) {
desiredPaths.add(baseDir);
}
const levelSkills = this.skillsCache?.get(level) || [];
for (const skill of levelSkills) {
const skillDir = path.dirname(skill.filePath);
if (fsSync.existsSync(skillDir)) {
desiredPaths.add(skillDir);
}
}
}
for (const existingPath of this.watchers.keys()) {
if (!watchTargets.has(existingPath)) {
if (!desiredPaths.has(existingPath)) {
void this.watchers
.get(existingPath)
?.close()
@@ -508,7 +522,7 @@ export class SkillManager {
}
}
for (const watchPath of watchTargets) {
for (const watchPath of desiredPaths) {
if (this.watchers.has(watchPath)) {
continue;
}
@@ -543,16 +557,4 @@ export class SkillManager {
void this.refreshCache().then(() => this.updateWatchersFromCache());
}, 150);
}
private async ensureUserSkillsDir(): Promise<void> {
const baseDir = this.getSkillsBaseDir('user');
try {
await fs.mkdir(baseDir, { recursive: true });
} catch (error) {
console.warn(
`Failed to create user skills directory at ${baseDir}:`,
error,
);
}
}
}

View File

@@ -53,7 +53,7 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
false, // canUpdateOutput
);
this.skillManager = config.getSkillManager()!;
this.skillManager = config.getSkillManager();
this.skillManager.addChangeListener(() => {
void this.refreshSkills();
});