mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(cli): export chat history in /bug and prefill GitHub issue (#16115)
This commit is contained in:
@@ -56,3 +56,4 @@ patch_output.log
|
|||||||
|
|
||||||
.genkit
|
.genkit
|
||||||
.gemini-clipboard/
|
.gemini-clipboard/
|
||||||
|
.eslintcache
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
|
import path from 'node:path';
|
||||||
import { bugCommand } from './bugCommand.js';
|
import { bugCommand } from './bugCommand.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import { getVersion } from '@google/gemini-cli-core';
|
import { getVersion } from '@google/gemini-cli-core';
|
||||||
@@ -15,6 +16,16 @@ import { formatMemoryUsage } from '../utils/formatters.js';
|
|||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('open');
|
vi.mock('open');
|
||||||
vi.mock('../utils/formatters.js');
|
vi.mock('../utils/formatters.js');
|
||||||
|
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
exportHistoryToFile: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
|
||||||
|
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
@@ -27,6 +38,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
},
|
},
|
||||||
sessionId: 'test-session-id',
|
sessionId: 'test-session-id',
|
||||||
getVersion: vi.fn(),
|
getVersion: vi.fn(),
|
||||||
|
INITIAL_HISTORY_LENGTH: 1,
|
||||||
|
debugLogger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
log: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock('node:process', () => ({
|
vi.mock('node:process', () => ({
|
||||||
@@ -52,11 +70,14 @@ describe('bugCommand', () => {
|
|||||||
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
||||||
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
|
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
|
||||||
vi.stubEnv('SANDBOX', 'gemini-test');
|
vi.stubEnv('SANDBOX', 'gemini-test');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate the default GitHub issue URL', async () => {
|
it('should generate the default GitHub issue URL', async () => {
|
||||||
@@ -66,6 +87,11 @@ describe('bugCommand', () => {
|
|||||||
getModel: () => 'gemini-pro',
|
getModel: () => 'gemini-pro',
|
||||||
getBugCommand: () => undefined,
|
getBugCommand: () => undefined,
|
||||||
getIdeMode: () => true,
|
getIdeMode: () => true,
|
||||||
|
getGeminiClient: () => ({
|
||||||
|
getChat: () => ({
|
||||||
|
getHistory: () => [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -86,13 +112,58 @@ describe('bugCommand', () => {
|
|||||||
* **Kitty Keyboard Protocol:** Supported
|
* **Kitty Keyboard Protocol:** Supported
|
||||||
* **IDE Client:** VSCode
|
* **IDE Client:** VSCode
|
||||||
`;
|
`;
|
||||||
const expectedUrl =
|
const expectedUrl = `https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=${encodeURIComponent(expectedInfo)}&problem=A%20test%20bug`;
|
||||||
'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=' +
|
|
||||||
encodeURIComponent(expectedInfo);
|
|
||||||
|
|
||||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should export chat history if available', async () => {
|
||||||
|
const history = [
|
||||||
|
{ role: 'user', parts: [{ text: 'hello' }] },
|
||||||
|
{ role: 'model', parts: [{ text: 'hi' }] },
|
||||||
|
];
|
||||||
|
const mockContext = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: {
|
||||||
|
getModel: () => 'gemini-pro',
|
||||||
|
getBugCommand: () => undefined,
|
||||||
|
getIdeMode: () => true,
|
||||||
|
getGeminiClient: () => ({
|
||||||
|
getChat: () => ({
|
||||||
|
getHistory: () => history,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
storage: {
|
||||||
|
getProjectTempDir: () => '/tmp/gemini',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||||
|
await bugCommand.action(mockContext, 'Bug with history');
|
||||||
|
|
||||||
|
const expectedPath = path.join(
|
||||||
|
'/tmp/gemini',
|
||||||
|
'bug-report-history-1704067200000.json',
|
||||||
|
);
|
||||||
|
expect(exportHistoryToFile).toHaveBeenCalledWith({
|
||||||
|
history,
|
||||||
|
filePath: expectedPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0];
|
||||||
|
const messageText = addItemCall[0].text;
|
||||||
|
expect(messageText).toContain(expectedPath);
|
||||||
|
expect(messageText).toContain('📄 **Chat History Exported**');
|
||||||
|
expect(messageText).toContain('Privacy Disclaimer:');
|
||||||
|
expect(messageText).not.toContain('additional-context=');
|
||||||
|
expect(messageText).toContain('problem=');
|
||||||
|
const reminder =
|
||||||
|
'\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.';
|
||||||
|
expect(messageText).toContain(encodeURIComponent(reminder));
|
||||||
|
});
|
||||||
|
|
||||||
it('should use a custom URL template from config if provided', async () => {
|
it('should use a custom URL template from config if provided', async () => {
|
||||||
const customTemplate =
|
const customTemplate =
|
||||||
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
|
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
|
||||||
@@ -102,6 +173,11 @@ describe('bugCommand', () => {
|
|||||||
getModel: () => 'gemini-pro',
|
getModel: () => 'gemini-pro',
|
||||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||||
getIdeMode: () => true,
|
getIdeMode: () => true,
|
||||||
|
getGeminiClient: () => ({
|
||||||
|
getChat: () => ({
|
||||||
|
getHistory: () => [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ import {
|
|||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||||
import { IdeClient, sessionId, getVersion } from '@google/gemini-cli-core';
|
import {
|
||||||
|
IdeClient,
|
||||||
|
sessionId,
|
||||||
|
getVersion,
|
||||||
|
INITIAL_HISTORY_LENGTH,
|
||||||
|
debugLogger,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||||
|
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
export const bugCommand: SlashCommand = {
|
export const bugCommand: SlashCommand = {
|
||||||
name: 'bug',
|
name: 'bug',
|
||||||
@@ -63,8 +71,31 @@ export const bugCommand: SlashCommand = {
|
|||||||
info += `* **IDE Client:** ${ideClient}\n`;
|
info += `* **IDE Client:** ${ideClient}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chat = config?.getGeminiClient()?.getChat();
|
||||||
|
const history = chat?.getHistory() || [];
|
||||||
|
let historyFileMessage = '';
|
||||||
|
let problemValue = bugDescription;
|
||||||
|
|
||||||
|
if (history.length > INITIAL_HISTORY_LENGTH) {
|
||||||
|
const tempDir = config?.storage?.getProjectTempDir();
|
||||||
|
if (tempDir) {
|
||||||
|
const historyFileName = `bug-report-history-${Date.now()}.json`;
|
||||||
|
const historyFilePath = path.join(tempDir, historyFileName);
|
||||||
|
try {
|
||||||
|
await exportHistoryToFile({ history, filePath: historyFilePath });
|
||||||
|
historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\n📄 **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`;
|
||||||
|
problemValue += `\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
debugLogger.error(
|
||||||
|
`Failed to export chat history for bug report: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let bugReportUrl =
|
let bugReportUrl =
|
||||||
'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}';
|
'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}&problem={problem}';
|
||||||
|
|
||||||
const bugCommandSettings = config?.getBugCommand();
|
const bugCommandSettings = config?.getBugCommand();
|
||||||
if (bugCommandSettings?.urlTemplate) {
|
if (bugCommandSettings?.urlTemplate) {
|
||||||
@@ -73,12 +104,13 @@ export const bugCommand: SlashCommand = {
|
|||||||
|
|
||||||
bugReportUrl = bugReportUrl
|
bugReportUrl = bugReportUrl
|
||||||
.replace('{title}', encodeURIComponent(bugDescription))
|
.replace('{title}', encodeURIComponent(bugDescription))
|
||||||
.replace('{info}', encodeURIComponent(info));
|
.replace('{info}', encodeURIComponent(info))
|
||||||
|
.replace('{problem}', encodeURIComponent(problemValue));
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`,
|
text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}${historyFileMessage}`,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Mocked } from 'vitest';
|
|
||||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
import type { SlashCommand, CommandContext } from './types.js';
|
import type { SlashCommand, CommandContext } from './types.js';
|
||||||
@@ -13,7 +12,11 @@ import type { Content } from '@google/genai';
|
|||||||
import { AuthType, type GeminiClient } from '@google/gemini-cli-core';
|
import { AuthType, type GeminiClient } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
import * as fsPromises from 'node:fs/promises';
|
import * as fsPromises from 'node:fs/promises';
|
||||||
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
|
import { chatCommand } from './chatCommand.js';
|
||||||
|
import {
|
||||||
|
serializeHistoryToMarkdown,
|
||||||
|
exportHistoryToFile,
|
||||||
|
} from '../utils/historyExportUtils.js';
|
||||||
import type { Stats } from 'node:fs';
|
import type { Stats } from 'node:fs';
|
||||||
import type { HistoryItemWithoutId } from '../types.js';
|
import type { HistoryItemWithoutId } from '../types.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -24,8 +27,18 @@ vi.mock('fs/promises', () => ({
|
|||||||
writeFile: vi.fn(),
|
writeFile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
exportHistoryToFile: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('chatCommand', () => {
|
describe('chatCommand', () => {
|
||||||
const mockFs = fsPromises as Mocked<typeof fsPromises>;
|
const mockFs = vi.mocked(fsPromises);
|
||||||
|
const mockExport = vi.mocked(exportHistoryToFile);
|
||||||
|
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
let mockGetChat: ReturnType<typeof vi.fn>;
|
let mockGetChat: ReturnType<typeof vi.fn>;
|
||||||
@@ -448,9 +461,10 @@ describe('chatCommand', () => {
|
|||||||
process.cwd(),
|
process.cwd(),
|
||||||
'gemini-conversation-1234567890.json',
|
'gemini-conversation-1234567890.json',
|
||||||
);
|
);
|
||||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
expect(mockExport).toHaveBeenCalledWith({
|
||||||
expect(actualPath).toEqual(expectedPath);
|
history: mockHistory,
|
||||||
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
|
filePath: expectedPath,
|
||||||
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
@@ -462,9 +476,10 @@ describe('chatCommand', () => {
|
|||||||
const filePath = 'my-chat.json';
|
const filePath = 'my-chat.json';
|
||||||
const result = await shareCommand?.action?.(mockContext, filePath);
|
const result = await shareCommand?.action?.(mockContext, filePath);
|
||||||
const expectedPath = path.join(process.cwd(), 'my-chat.json');
|
const expectedPath = path.join(process.cwd(), 'my-chat.json');
|
||||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
expect(mockExport).toHaveBeenCalledWith({
|
||||||
expect(actualPath).toEqual(expectedPath);
|
history: mockHistory,
|
||||||
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
|
filePath: expectedPath,
|
||||||
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
@@ -476,30 +491,10 @@ describe('chatCommand', () => {
|
|||||||
const filePath = 'my-chat.md';
|
const filePath = 'my-chat.md';
|
||||||
const result = await shareCommand?.action?.(mockContext, filePath);
|
const result = await shareCommand?.action?.(mockContext, filePath);
|
||||||
const expectedPath = path.join(process.cwd(), 'my-chat.md');
|
const expectedPath = path.join(process.cwd(), 'my-chat.md');
|
||||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
expect(mockExport).toHaveBeenCalledWith({
|
||||||
expect(actualPath).toEqual(expectedPath);
|
history: mockHistory,
|
||||||
const expectedContent = `## USER 🧑💻
|
filePath: expectedPath,
|
||||||
|
});
|
||||||
context
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MODEL ✨
|
|
||||||
|
|
||||||
context response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## USER 🧑💻
|
|
||||||
|
|
||||||
Hello
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MODEL ✨
|
|
||||||
|
|
||||||
Hi there!`;
|
|
||||||
expect(actualContent).toEqual(expectedContent);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
@@ -510,7 +505,7 @@ Hi there!`;
|
|||||||
it('should return an error for unsupported file extensions', async () => {
|
it('should return an error for unsupported file extensions', async () => {
|
||||||
const filePath = 'my-chat.txt';
|
const filePath = 'my-chat.txt';
|
||||||
const result = await shareCommand?.action?.(mockContext, filePath);
|
const result = await shareCommand?.action?.(mockContext, filePath);
|
||||||
expect(mockFs.writeFile).not.toHaveBeenCalled();
|
expect(mockExport).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
@@ -523,7 +518,7 @@ Hi there!`;
|
|||||||
{ role: 'user', parts: [{ text: 'context' }] },
|
{ role: 'user', parts: [{ text: 'context' }] },
|
||||||
]);
|
]);
|
||||||
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
|
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
|
||||||
expect(mockFs.writeFile).not.toHaveBeenCalled();
|
expect(mockExport).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
@@ -533,7 +528,7 @@ Hi there!`;
|
|||||||
|
|
||||||
it('should handle errors during file writing', async () => {
|
it('should handle errors during file writing', async () => {
|
||||||
const error = new Error('Permission denied');
|
const error = new Error('Permission denied');
|
||||||
mockFs.writeFile.mockRejectedValue(error);
|
mockExport.mockRejectedValue(error);
|
||||||
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
|
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -546,14 +541,9 @@ Hi there!`;
|
|||||||
const filePath = 'my-chat.json';
|
const filePath = 'my-chat.json';
|
||||||
await shareCommand?.action?.(mockContext, filePath);
|
await shareCommand?.action?.(mockContext, filePath);
|
||||||
const expectedPath = path.join(process.cwd(), 'my-chat.json');
|
const expectedPath = path.join(process.cwd(), 'my-chat.json');
|
||||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
expect(mockExport).toHaveBeenCalledWith({
|
||||||
expect(actualPath).toEqual(expectedPath);
|
history: mockHistory,
|
||||||
const parsedContent = JSON.parse(actualContent as string);
|
filePath: expectedPath,
|
||||||
expect(Array.isArray(parsedContent)).toBe(true);
|
|
||||||
parsedContent.forEach((item: Content) => {
|
|
||||||
expect(item).toHaveProperty('role');
|
|
||||||
expect(item).toHaveProperty('parts');
|
|
||||||
expect(Array.isArray(item.parts)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,15 +551,9 @@ Hi there!`;
|
|||||||
const filePath = 'my-chat.md';
|
const filePath = 'my-chat.md';
|
||||||
await shareCommand?.action?.(mockContext, filePath);
|
await shareCommand?.action?.(mockContext, filePath);
|
||||||
const expectedPath = path.join(process.cwd(), 'my-chat.md');
|
const expectedPath = path.join(process.cwd(), 'my-chat.md');
|
||||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
expect(mockExport).toHaveBeenCalledWith({
|
||||||
expect(actualPath).toEqual(expectedPath);
|
history: mockHistory,
|
||||||
const entries = (actualContent as string).split('\n\n---\n\n');
|
filePath: expectedPath,
|
||||||
expect(entries.length).toBe(mockHistory.length);
|
|
||||||
entries.forEach((entry: string, index: number) => {
|
|
||||||
const { role, parts } = mockHistory[index];
|
|
||||||
const text = parts.map((p) => p.text).join('');
|
|
||||||
const roleIcon = role === 'user' ? '🧑💻' : '✨';
|
|
||||||
expect(entry).toBe(`## ${role.toUpperCase()} ${roleIcon}\n\n${text}`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import type {
|
|||||||
ChatDetail,
|
ChatDetail,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { Content } from '@google/genai';
|
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
|
||||||
|
|
||||||
const getSavedChatTags = async (
|
const getSavedChatTags = async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -272,38 +272,6 @@ const deleteCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeHistoryToMarkdown(history: Content[]): string {
|
|
||||||
return history
|
|
||||||
.map((item) => {
|
|
||||||
const text =
|
|
||||||
item.parts
|
|
||||||
?.map((part) => {
|
|
||||||
if (part.text) {
|
|
||||||
return part.text;
|
|
||||||
}
|
|
||||||
if (part.functionCall) {
|
|
||||||
return `**Tool Command**:\n\`\`\`json\n${JSON.stringify(
|
|
||||||
part.functionCall,
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n\`\`\``;
|
|
||||||
}
|
|
||||||
if (part.functionResponse) {
|
|
||||||
return `**Tool Response**:\n\`\`\`json\n${JSON.stringify(
|
|
||||||
part.functionResponse,
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n\`\`\``;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.join('') || '';
|
|
||||||
const roleIcon = item.role === 'user' ? '🧑💻' : '✨';
|
|
||||||
return `## ${(item.role || 'model').toUpperCase()} ${roleIcon}\n\n${text}`;
|
|
||||||
})
|
|
||||||
.join('\n\n---\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const shareCommand: SlashCommand = {
|
const shareCommand: SlashCommand = {
|
||||||
name: 'share',
|
name: 'share',
|
||||||
description:
|
description:
|
||||||
@@ -348,15 +316,8 @@ const shareCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = '';
|
|
||||||
if (extension === '.json') {
|
|
||||||
content = JSON.stringify(history, null, 2);
|
|
||||||
} else {
|
|
||||||
content = serializeHistoryToMarkdown(history);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsPromises.writeFile(filePath, content);
|
await exportHistoryToFile({ history, filePath });
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fsPromises from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { Content } from '@google/genai';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes chat history to a Markdown string.
|
||||||
|
*/
|
||||||
|
export function serializeHistoryToMarkdown(history: Content[]): string {
|
||||||
|
return history
|
||||||
|
.map((item) => {
|
||||||
|
const text =
|
||||||
|
item.parts
|
||||||
|
?.map((part) => {
|
||||||
|
if (part.text) {
|
||||||
|
return part.text;
|
||||||
|
}
|
||||||
|
if (part.functionCall) {
|
||||||
|
return (
|
||||||
|
`**Tool Command**:\n` +
|
||||||
|
'```json\n' +
|
||||||
|
JSON.stringify(part.functionCall, null, 2) +
|
||||||
|
'\n```'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (part.functionResponse) {
|
||||||
|
return (
|
||||||
|
`**Tool Response**:\n` +
|
||||||
|
'```json\n' +
|
||||||
|
JSON.stringify(part.functionResponse, null, 2) +
|
||||||
|
'\n```'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('') || '';
|
||||||
|
const roleIcon = item.role === 'user' ? '🧑💻' : '✨';
|
||||||
|
return `## ${(item.role || 'model').toUpperCase()} ${roleIcon}\n\n${text}`;
|
||||||
|
})
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for exporting chat history.
|
||||||
|
*/
|
||||||
|
export interface ExportHistoryOptions {
|
||||||
|
history: Content[];
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports chat history to a file (JSON or Markdown).
|
||||||
|
*/
|
||||||
|
export async function exportHistoryToFile(
|
||||||
|
options: ExportHistoryOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const { history, filePath } = options;
|
||||||
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
if (extension === '.json') {
|
||||||
|
content = JSON.stringify(history, null, 2);
|
||||||
|
} else if (extension === '.md') {
|
||||||
|
content = serializeHistoryToMarkdown(history);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported file extension: ${extension}. Use .json or .md.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await fsPromises.mkdir(dir, { recursive: true });
|
||||||
|
await fsPromises.writeFile(filePath, content, 'utf-8');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user