fix import (#242)
* 🐛 fix import (#224) * 🐛 fix import of corrupted history see https://github.com/mckaywrigley/chatbot-ui/issues/224#issuecomment-1486080888 * add the run-test-suite github action
This commit is contained in:
parent
5aa5be3f43
commit
b0c289f7a4
|
@ -1,4 +1,4 @@
|
|||
.env
|
||||
.env.local
|
||||
node_modules
|
||||
|
||||
test-results
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
name: Run Jest Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:16
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Jest Test Suite
|
||||
run: npm test
|
||||
|
||||
- name: Publish Test Report
|
||||
if: always()
|
||||
uses: EnricoMi/publish-unit-test-result-action@v1
|
||||
with:
|
||||
files: test-results/**/results.xml
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
import { ExportFormatV1, ExportFormatV2 } from '@/types/export';
|
||||
import { OpenAIModels, OpenAIModelID } from '@/types/openai';
|
||||
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
|
||||
import {
|
||||
cleanData,
|
||||
isExportFormatV1,
|
||||
isExportFormatV2,
|
||||
isExportFormatV3,
|
||||
isLatestExportFormat,
|
||||
} from '@/utils/app/importExport';
|
||||
|
||||
describe('Export Format Functions', () => {
|
||||
describe('isExportFormatV1', () => {
|
||||
it('should return true for v1 format', () => {
|
||||
const obj = [{ id: 1 }];
|
||||
expect(isExportFormatV1(obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-v1 formats', () => {
|
||||
const obj = { version: 3, history: [], folders: [] };
|
||||
expect(isExportFormatV1(obj)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExportFormatV2', () => {
|
||||
it('should return true for v2 format', () => {
|
||||
const obj = { history: [], folders: [] };
|
||||
expect(isExportFormatV2(obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-v2 formats', () => {
|
||||
const obj = { version: 3, history: [], folders: [] };
|
||||
expect(isExportFormatV2(obj)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExportFormatV3', () => {
|
||||
it('should return true for v3 format', () => {
|
||||
const obj = { version: 3, history: [], folders: [] };
|
||||
expect(isExportFormatV3(obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-v3 formats', () => {
|
||||
const obj = { version: 4, history: [], folders: [] };
|
||||
expect(isExportFormatV3(obj)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanData Functions', () => {
|
||||
describe('cleaning v1 data', () => {
|
||||
it('should return the latest format', () => {
|
||||
const data = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as ExportFormatV1;
|
||||
const obj = cleanData(data);
|
||||
expect(isLatestExportFormat(obj)).toBe(true);
|
||||
expect(obj).toEqual({
|
||||
version: 3,
|
||||
history: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
folders: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleaning v2 data', () => {
|
||||
it('should return the latest format', () => {
|
||||
const data = {
|
||||
history: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'folder 1',
|
||||
},
|
||||
],
|
||||
} as ExportFormatV2;
|
||||
const obj = cleanData(data);
|
||||
expect(isLatestExportFormat(obj)).toBe(true);
|
||||
expect(obj).toEqual({
|
||||
version: 3,
|
||||
history: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'folder 1',
|
||||
type: 'chat',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,6 @@
|
|||
import { Conversation } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { SupportedExportFormats } from '@/types/export';
|
||||
import { Folder } from '@/types/folder';
|
||||
import {
|
||||
IconArrowBarLeft,
|
||||
|
@ -36,10 +37,7 @@ interface Props {
|
|||
onApiKeyChange: (apiKey: string) => void;
|
||||
onClearConversations: () => void;
|
||||
onExportConversations: () => void;
|
||||
onImportConversations: (data: {
|
||||
conversations: Conversation[];
|
||||
folders: Folder[];
|
||||
}) => void;
|
||||
onImportConversations: (data: SupportedExportFormats) => void;
|
||||
}
|
||||
|
||||
export const Chatbar: FC<Props> = ({
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { Conversation } from '@/types/chat';
|
||||
import { Folder } from '@/types/folder';
|
||||
import { SupportedExportFormats } from '@/types/export';
|
||||
import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC } from 'react';
|
||||
|
@ -16,10 +15,7 @@ interface Props {
|
|||
onApiKeyChange: (apiKey: string) => void;
|
||||
onClearConversations: () => void;
|
||||
onExportConversations: () => void;
|
||||
onImportConversations: (data: {
|
||||
conversations: Conversation[];
|
||||
folders: Folder[];
|
||||
}) => void;
|
||||
onImportConversations: (data: SupportedExportFormats) => void;
|
||||
}
|
||||
|
||||
export const ChatbarSettings: FC<Props> = ({
|
||||
|
|
|
@ -1,16 +1,11 @@
|
|||
import { Conversation } from '@/types/chat';
|
||||
import { Folder } from '@/types/folder';
|
||||
import { cleanConversationHistory } from '@/utils/app/clean';
|
||||
import { SupportedExportFormats } from '@/types/export';
|
||||
import { IconFileImport } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC } from 'react';
|
||||
import { SidebarButton } from '../Sidebar/SidebarButton';
|
||||
|
||||
interface Props {
|
||||
onImport: (data: {
|
||||
conversations: Conversation[];
|
||||
folders: Folder[];
|
||||
}) => void;
|
||||
onImport: (data: SupportedExportFormats) => void;
|
||||
}
|
||||
|
||||
export const Import: FC<Props> = ({ onImport }) => {
|
||||
|
@ -30,12 +25,7 @@ export const Import: FC<Props> = ({ onImport }) => {
|
|||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
let json = JSON.parse(e.target?.result as string);
|
||||
|
||||
if (json && !json.folders) {
|
||||
json = { history: cleanConversationHistory(json), folders: [] };
|
||||
}
|
||||
|
||||
onImport({ conversations: json.history, folders: json.folders });
|
||||
onImport(json);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
verbose: true,
|
||||
preset: 'ts-jest',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'jest-junit',
|
||||
{
|
||||
outputDirectory: 'test-results/jest',
|
||||
outputName: 'results.xml',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
|
@ -7,7 +7,8 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dqbd/tiktoken": "^1.0.2",
|
||||
|
@ -29,6 +30,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
|
@ -37,10 +41,15 @@
|
|||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"jest-junit": "^15.0.0",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"ts-jest": "^29.0.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "4.9.5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Promptbar } from '@/components/Promptbar/Promptbar';
|
|||
import { ChatBody, Conversation, Message } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { ErrorMessage } from '@/types/error';
|
||||
import { LatestExportFormat, SupportedExportFormats } from '@/types/export';
|
||||
import { Folder, FolderType } from '@/types/folder';
|
||||
import { OpenAIModel, OpenAIModelID, OpenAIModels } from '@/types/openai';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
@ -285,19 +286,12 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
|
|||
exportData();
|
||||
};
|
||||
|
||||
const handleImportConversations = (data: {
|
||||
conversations: Conversation[];
|
||||
folders: Folder[];
|
||||
}) => {
|
||||
const updatedConversations = [...conversations, ...data.conversations];
|
||||
const updatedFolders = [...folders, ...data.folders];
|
||||
const handleImportConversations = (data: SupportedExportFormats) => {
|
||||
const { history, folders }: LatestExportFormat = importData(data);
|
||||
|
||||
importData(updatedConversations, updatedFolders);
|
||||
setConversations(updatedConversations);
|
||||
setSelectedConversation(
|
||||
updatedConversations[updatedConversations.length - 1],
|
||||
);
|
||||
setFolders(updatedFolders);
|
||||
setConversations(history);
|
||||
setSelectedConversation(history[history.length - 1]);
|
||||
setFolders(folders);
|
||||
};
|
||||
|
||||
const handleSelectConversation = (conversation: Conversation) => {
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
@ -15,9 +19,21 @@
|
|||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "jsdom"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { Conversation, Message } from './chat';
|
||||
import { Folder } from './folder';
|
||||
import { OpenAIModel } from './openai';
|
||||
|
||||
export type SupportedExportFormats =
|
||||
| ExportFormatV1
|
||||
| ExportFormatV2
|
||||
| ExportFormatV3;
|
||||
export type LatestExportFormat = ExportFormatV3;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
interface ConversationV1 {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export type ExportFormatV1 = ConversationV1[];
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
interface ChatFolder {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ExportFormatV2 {
|
||||
history: Conversation[] | null;
|
||||
folders: ChatFolder[] | null;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
export interface ExportFormatV3 {
|
||||
version: 3;
|
||||
history: Conversation[];
|
||||
folders: Folder[];
|
||||
}
|
|
@ -36,13 +36,18 @@ export const cleanSelectedConversation = (conversation: Conversation) => {
|
|||
return updatedConversation;
|
||||
};
|
||||
|
||||
export const cleanConversationHistory = (history: Conversation[]) => {
|
||||
export const cleanConversationHistory = (history: any[]): Conversation[] => {
|
||||
// added model for each conversation (3/20/23)
|
||||
// added system prompt for each conversation (3/21/23)
|
||||
// added folders (3/23/23)
|
||||
// added prompts (3/26/23)
|
||||
|
||||
return history.reduce((acc: Conversation[], conversation) => {
|
||||
if (!Array.isArray(history)) {
|
||||
console.warn('history is not an array. Returning an empty array.');
|
||||
return [];
|
||||
}
|
||||
|
||||
return history.reduce((acc: any[], conversation) => {
|
||||
try {
|
||||
if (!conversation.model) {
|
||||
conversation.model = OpenAIModels[OpenAIModelID.GPT_3_5];
|
||||
|
|
|
@ -1,5 +1,53 @@
|
|||
import { Conversation } from '@/types/chat';
|
||||
import { Folder } from '@/types/folder';
|
||||
import {
|
||||
ExportFormatV1,
|
||||
ExportFormatV2,
|
||||
ExportFormatV3,
|
||||
LatestExportFormat,
|
||||
SupportedExportFormats,
|
||||
} from '@/types/export';
|
||||
import { cleanConversationHistory } from './clean';
|
||||
|
||||
export function isExportFormatV1(obj: any): obj is ExportFormatV1 {
|
||||
return Array.isArray(obj);
|
||||
}
|
||||
|
||||
export function isExportFormatV2(obj: any): obj is ExportFormatV2 {
|
||||
return !('version' in obj) && 'folders' in obj && 'history' in obj;
|
||||
}
|
||||
|
||||
export function isExportFormatV3(obj: any): obj is ExportFormatV3 {
|
||||
return obj.version === 3;
|
||||
}
|
||||
|
||||
export const isLatestExportFormat = isExportFormatV3;
|
||||
|
||||
export function cleanData(data: SupportedExportFormats): LatestExportFormat {
|
||||
if (isExportFormatV1(data)) {
|
||||
return {
|
||||
version: 3,
|
||||
history: cleanConversationHistory(data),
|
||||
folders: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isExportFormatV2(data)) {
|
||||
return {
|
||||
version: 3,
|
||||
history: cleanConversationHistory(data.history || []),
|
||||
folders: (data.folders || []).map((chatFolder) => ({
|
||||
id: chatFolder.id.toString(),
|
||||
name: chatFolder.name,
|
||||
type: 'chat',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (isExportFormatV3(data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data format');
|
||||
}
|
||||
|
||||
function currentDate() {
|
||||
const date = new Date();
|
||||
|
@ -21,9 +69,10 @@ export const exportData = () => {
|
|||
}
|
||||
|
||||
const data = {
|
||||
history,
|
||||
folders,
|
||||
};
|
||||
version: 3,
|
||||
history: history || [],
|
||||
folders: folders || [],
|
||||
} as LatestExportFormat;
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json',
|
||||
|
@ -40,13 +89,18 @@ export const exportData = () => {
|
|||
};
|
||||
|
||||
export const importData = (
|
||||
conversations: Conversation[],
|
||||
folders: Folder[],
|
||||
) => {
|
||||
data: SupportedExportFormats,
|
||||
): LatestExportFormat => {
|
||||
const cleanedData = cleanData(data);
|
||||
|
||||
const conversations = cleanedData.history;
|
||||
localStorage.setItem('conversationHistory', JSON.stringify(conversations));
|
||||
localStorage.setItem(
|
||||
'selectedConversation',
|
||||
JSON.stringify(conversations[conversations.length - 1]),
|
||||
);
|
||||
localStorage.setItem('folders', JSON.stringify(folders));
|
||||
|
||||
localStorage.setItem('folders', JSON.stringify(cleanedData.folders));
|
||||
|
||||
return cleanedData;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue