* fix(UI): #250 better chat left side bar

Signed-off-by: James <james@jan.ai>
Co-authored-by: James <james@jan.ai>
This commit is contained in:
NamH 2023-10-15 07:46:15 -07:00 committed by GitHub
parent 4c03946ef4
commit 84dd54a98c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 5922 additions and 6129 deletions

View File

@ -151,6 +151,7 @@ export function init({ register }: { register: RegisterExtensionPoint }) {
register(DataService.GetConversations, getConversations.name, getConversations);
register(DataService.CreateConversation, createConversation.name, createConversation);
register(DataService.UpdateConversation, updateConversation.name, updateConversation);
register(DataService.UpdateMessage, updateMessage.name, updateMessage);
register(DataService.DeleteConversation, deleteConversation.name, deleteConversation);
register(DataService.CreateMessage, createMessage.name, createMessage);
@ -160,13 +161,19 @@ export function init({ register }: { register: RegisterExtensionPoint }) {
function getConversations(): Promise<any> {
return store.findMany("conversations", {}, [{ updatedAt: "desc" }]);
}
function createConversation(conversation: any): Promise<number | undefined> {
return store.insertOne("conversations", conversation);
}
function updateConversation(conversation: any): Promise<void> {
return store.updateOne("conversations", conversation._id, conversation);
}
function createMessage(message: any): Promise<number | undefined> {
return store.insertOne("messages", message);
}
function updateMessage(message: any): Promise<void> {
return store.updateOne("messages", message._id, message);
}

View File

@ -59,13 +59,18 @@ function insertOne(collectionName: string, value: any): Promise<any> {
*
*/
function updateOne(collectionName: string, key: string, value: any): Promise<void> {
console.debug(`updateOne ${collectionName}: ${key} - ${JSON.stringify(value)}`);
return dbs[collectionName].get(key).then((doc) => {
return dbs[collectionName].put({
_id: key,
_rev: doc._rev,
force: true,
...value,
});
},
{ force: true });
}).then((res: any) => {
console.info(`updateOne ${collectionName} result: ${JSON.stringify(res)}`);
}).catch((err: any) => {
console.error(`updateOne ${collectionName} error: ${err}`);
});
}

View File

@ -1,6 +1,6 @@
{
"name": "data-plugin",
"version": "1.0.3",
"version": "1.0.4",
"description": "The Data Connector provides easy access to a data API using the PouchDB engine. It offers accessible data management capabilities.",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/circle-stack.svg",
"main": "dist/esm/index.js",

109
package-lock.json generated
View File

@ -326,6 +326,21 @@
"node": ">=10"
}
},
"node_modules/@emotion/is-prop-valid": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
"optional": true,
"dependencies": {
"@emotion/memoize": "0.7.4"
}
},
"node_modules/@emotion/memoize": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
@ -559,6 +574,10 @@
"integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==",
"license": "ISC"
},
"node_modules/@janhq/plugin-core": {
"resolved": "plugin-core",
"link": true
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
@ -5454,6 +5473,29 @@
"node": ">=0.10.0"
}
},
"node_modules/framer-motion": {
"version": "10.16.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.4.tgz",
"integrity": "sha512-p9V9nGomS3m6/CALXqv6nFGMuFOxbWsmaOrdmhyQimMIlLl3LC7h7l86wge/Js/8cRu5ktutS/zlzgR7eBOtFA==",
"dependencies": {
"tslib": "^2.4.0"
},
"optionalDependencies": {
"@emotion/is-prop-valid": "^0.8.2"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@ -7532,9 +7574,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
@ -7666,6 +7706,25 @@
"node": ">=0.10.0"
}
},
"node_modules/marked": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-9.1.2.tgz",
"integrity": "sha512-qoKMJqK0w6vkLk8+KnKZAH6neUZSNaQqVZ/h2yZ9S7CbLuFHyS2viB0jnqcWF9UKjwsAbMrQtnQhdmdvOVOw9w==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/marked-highlight": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.0.6.tgz",
"integrity": "sha512-xjA/C6xgXAfkkYg+YHnxdjmgFyTDtqqu8KbZiqh+COJ7PuzR15kqa+rPrs6pf/2jExXtG1jyCFUHmv9s0Bi/dQ==",
"peerDependencies": {
"marked": ">=4 <10"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@ -10204,7 +10263,6 @@
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
@ -13819,12 +13877,41 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"plugin-core": {
"name": "@janhq/plugin-core",
"version": "0.1.5",
"license": "MIT",
"devDependencies": {
"@types/node": "^12.0.2",
"typescript": "^5.2.2"
}
},
"plugin-core/node_modules/@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
"dev": true
},
"plugin-core/node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"web": {
"name": "jan-web",
"version": "0.1.0",
"dependencies": {
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"@janhq/plugin-core": "file:../plugin-core",
"@tailwindcss/typography": "^0.5.9",
"@types/react": "18.2.15",
"@types/react-dom": "18.2.7",
@ -13835,14 +13922,20 @@
"embla-carousel-react": "^8.0.0-rc11",
"eslint": "8.45.0",
"eslint-config-next": "13.4.10",
"framer-motion": "^10.16.4",
"highlight.js": "^11.9.0",
"jotai": "^2.4.0",
"jotai-optics": "^0.3.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"marked": "^9.1.2",
"marked-highlight": "^2.0.6",
"next": "13.4.10",
"next-auth": "^4.23.1",
"next-themes": "^0.2.1",
"optics-ts": "^2.4.1",
"postcss": "8.4.26",
"prismjs": "^1.29.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.4",
@ -13898,6 +13991,14 @@
"postcss": "^8.1.0"
}
},
"web/node_modules/highlight.js": {
"version": "11.9.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz",
"integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==",
"engines": {
"node": ">=12.0.0"
}
},
"web/node_modules/postcss": {
"version": "8.4.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.26.tgz",

View File

@ -76,6 +76,11 @@ export enum DataService {
*/
CreateConversation = "createConversation",
/**
* Updates an existing conversation on the server.
*/
UpdateConversation = "updateConversation",
/**
* Deletes an existing conversation from the server.
*/

View File

@ -1,5 +1,4 @@
import React from "react";
import JanImage from "../JanImage";
import { useAtomValue, useSetAtom } from "jotai";
import Image from "next/image";
import { Conversation } from "@/_models/Conversation";
@ -17,11 +16,13 @@ import {
MainViewState,
} from "@/_helpers/atoms/MainView.atom";
import useInitModel from "@/_hooks/useInitModel";
import { displayDate } from "@/_utils/datetime";
type Props = {
conversation: Conversation;
avatarUrl?: string;
name: string;
summary?: string;
updatedAt?: string;
};
@ -29,6 +30,7 @@ const HistoryItem: React.FC<Props> = ({
conversation,
avatarUrl,
name,
summary,
updatedAt,
}) => {
const setMainViewState = useSetAtom(setMainViewStateAtom);
@ -73,49 +75,39 @@ const HistoryItem: React.FC<Props> = ({
rightImageUrl = "icons/loading.svg";
}
const description = conversation?.lastMessage ?? "No new message";
return (
<button
className={`flex flex-row mx-1 items-center gap-2.5 rounded-lg p-2 ${backgroundColor} hover:bg-hover-light`}
<li
role="button"
className={`flex flex-row ml-3 mr-2 rounded p-3 ${backgroundColor} hover:bg-hover-light`}
onClick={onClick}
>
<Image
width={36}
height={36}
src={avatarUrl ?? "icons/app_icon.svg"}
className="w-9 aspect-square rounded-full"
alt=""
/>
<div className="flex flex-col justify-between text-sm leading-[20px] w-full">
<div className="flex flex-row items-center justify-between">
<span className="text-gray-900 text-left">{name}</span>
<span className="text-xs leading-[13px] tracking-[-0.4px] text-gray-400">
{updatedAt && new Date(updatedAt).toDateString()}
<div className="w-8 h-8">
<Image
width={32}
height={32}
src={avatarUrl ?? "icons/app_icon.svg"}
className="aspect-square rounded-full"
alt=""
/>
</div>
<div className="flex flex-col ml-2 flex-1">
{/* title */}
<div className="flex">
<span className="flex-1 text-gray-900 line-clamp-1">
{summary ?? name}
</span>
<span className="text-xs leading-5 text-gray-500 line-clamp-1">
{updatedAt && displayDate(new Date(updatedAt).getTime())}
</span>
</div>
<div className="flex items-center justify-between gap-1">
<div className="flex-1">
<span className="text-gray-400 hidden-text text-left">
{conversation?.message ?? (
<span>
No new message
<br className="h-5 block" />
</span>
)}
</span>
</div>
<>
{rightImageUrl != null ? (
<JanImage
imageUrl={rightImageUrl ?? ""}
className="rounded"
width={24}
height={24}
/>
) : undefined}
</>
</div>
{/* description */}
<span className="mt-1 text-gray-400 line-clamp-2">{description}</span>
</div>
</button>
</li>
);
};

View File

@ -24,8 +24,10 @@ const HistoryList: React.FC = () => {
expanded={expand}
onClick={() => setExpand(!expand)}
/>
<div
className={`flex flex-col gap-1 mt-1 overflow-y-auto scroll ${!expand ? "hidden " : "block"}`}
<ul
className={`flex flex-col gap-1 mt-1 overflow-y-auto scroll ${
!expand ? "hidden " : "block"
}`}
>
{conversations.length > 0 ? (
conversations
@ -38,6 +40,7 @@ const HistoryList: React.FC = () => {
<HistoryItem
key={convo._id}
conversation={convo}
summary={convo.summary}
avatarUrl={convo.image}
name={convo.name || "Jan"}
updatedAt={convo.updatedAt ?? ""}
@ -46,7 +49,7 @@ const HistoryList: React.FC = () => {
) : (
<SidebarEmptyHistory />
)}
</div>
</ul>
</div>
);
};

View File

@ -6,12 +6,10 @@ import { useAtomValue } from "jotai";
import { showingAdvancedPromptAtom } from "@/_helpers/atoms/Modal.atom";
import SecondaryButton from "../SecondaryButton";
import { Fragment } from "react";
import { PlusIcon, FaceSmileIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/24/outline";
import useCreateConversation from "@/_hooks/useCreateConversation";
import { activeAssistantModelAtom } from "@/_helpers/atoms/Model.atom";
import LoadingIndicator from "../LoadingIndicator";
import { currentConvoStateAtom } from "@/_helpers/atoms/Conversation.atom";
import SendButton from "../SendButton";
const InputToolbar: React.FC = () => {
const showingAdvancedPrompt = useAtomValue(showingAdvancedPromptAtom);

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { Fragment } from "react";
import SidebarFooter from "../SidebarFooter";
import SidebarHeader from "../SidebarHeader";
import SidebarMenu from "../SidebarMenu";
@ -6,13 +6,13 @@ import HistoryList from "../HistoryList";
import NewChatButton from "../NewChatButton";
const LeftContainer: React.FC = () => (
<div className="w-[323px] flex-shrink-0 py-3 h-screen border-r border-gray-200 flex flex-col">
<Fragment>
<SidebarHeader />
<NewChatButton />
<HistoryList />
<SidebarMenu />
<SidebarFooter />
</div>
</Fragment>
);
export default React.memo(LeftContainer);

View File

@ -0,0 +1,45 @@
"use client";
import React from "react";
import LeftContainer from "../LeftContainer";
import RightContainer from "../RightContainer";
import { Variants, motion } from "framer-motion";
import { useAtomValue } from "jotai";
import { leftSideBarExpandStateAtom } from "@/_helpers/atoms/LeftSideBarExpand.atom";
const leftSideBarVariants: Variants = {
show: {
x: 0,
width: 320,
opacity: 1,
transition: { duration: 0.1 },
},
hide: {
x: "-100%",
width: 0,
opacity: 0,
transition: { duration: 0.1 },
},
};
const MainContainer: React.FC = () => {
const leftSideBarExpand = useAtomValue(leftSideBarExpandStateAtom);
return (
<div className="flex">
<motion.div
initial={false}
animate={leftSideBarExpand ? "show" : "hide"}
variants={leftSideBarVariants}
className="w-80 flex-shrink-0 py-3 h-screen border-r border-gray-200 flex flex-col"
>
<LeftContainer />
</motion.div>
<div className="flex flex-col flex-1 h-screen">
<RightContainer />
</div>
</div>
);
};
export default MainContainer;

View File

@ -1,11 +1,12 @@
import { Fragment } from "react";
import MainView from "../MainView";
import MonitorBar from "../MonitorBar";
const RightContainer = () => (
<div className="flex flex-col flex-1 h-screen">
<Fragment>
<MainView />
<MonitorBar />
</div>
</Fragment>
);
export default RightContainer;

View File

@ -1,7 +1,6 @@
import { currentPromptAtom } from "@/_helpers/JotaiWrapper";
import { currentConvoStateAtom } from "@/_helpers/atoms/Conversation.atom";
import useSendChatMessage from "@/_hooks/useSendChatMessage";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import { useAtom, useAtomValue } from "jotai";
const SendButton: React.FC = () => {

View File

@ -1,10 +1,11 @@
import React from "react";
import { displayDate } from "@/_utils/datetime";
import { TextCode } from "../TextCode";
import { getMessageCode } from "@/_utils/message";
import Image from "next/image";
import { MessageSenderType } from "@/_models/ChatMessage";
import LoadingIndicator from "../LoadingIndicator";
import { Marked } from "marked";
import { markedHighlight } from "marked-highlight";
import hljs from "highlight.js";
type Props = {
avatarUrl: string;
@ -14,16 +15,26 @@ type Props = {
text?: string;
};
const renderMessageCode = (text: string) => {
return getMessageCode(text).map((item, i) => (
<div className="flex gap-1 flex-col" key={i}>
<p className="leading-[20px] whitespace-break-spaces text-sm font-normal dark:text-[#d1d5db]">
{item.text}
</p>
{item.code.trim().length > 0 && <TextCode text={item.code} />}
</div>
));
};
const marked = new Marked(
markedHighlight({
langPrefix: "hljs",
highlight(code, lang) {
if (lang === undefined || lang === "") {
return hljs.highlightAuto(code).value;
}
return hljs.highlight(code, { language: lang }).value;
},
}),
{
renderer: {
code(code, lang, escaped) {
return `<pre class="hljs"><code class="language-${escape(
lang ?? ""
)}">${escaped ? code : escape(code)}</code></pre>`;
},
},
}
);
const SimpleTextMessage: React.FC<Props> = ({
senderName,
@ -35,6 +46,8 @@ const SimpleTextMessage: React.FC<Props> = ({
const backgroundColor =
senderType === MessageSenderType.User ? "" : "bg-gray-100";
const parsedText = marked.parse(text);
return (
<div
className={`flex items-start gap-2 px-12 md:px-32 2xl:px-64 ${backgroundColor} py-5`}
@ -46,7 +59,7 @@ const SimpleTextMessage: React.FC<Props> = ({
height={32}
alt=""
/>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 w-full">
<div className="flex gap-1 justify-start items-baseline">
<div className="text-[#1B1B1B] text-sm font-extrabold leading-[15.2px] dark:text-[#d1d5db]">
{senderName}
@ -57,10 +70,11 @@ const SimpleTextMessage: React.FC<Props> = ({
</div>
{text === "" ? (
<LoadingIndicator />
) : text.includes("```") ? (
renderMessageCode(text)
) : (
<span className="text-sm leading-loose font-normal">{text}</span>
<span
className="text-sm leading-loose font-normal"
dangerouslySetInnerHTML={{ __html: parsedText }}
/>
)}
</div>
</div>

View File

@ -1,66 +0,0 @@
import React from "react";
import { displayDate } from "@/_utils/datetime";
import { TextCode } from "../TextCode";
import { getMessageCode } from "@/_utils/message";
import Image from "next/image";
import { useAtomValue } from "jotai";
import { currentStreamingMessageAtom } from "@/_helpers/atoms/ChatMessage.atom";
type Props = {
id: string;
avatarUrl?: string;
senderName: string;
createdAt: number;
text?: string;
};
const StreamTextMessage: React.FC<Props> = ({
id,
senderName,
createdAt,
avatarUrl = "",
text = "",
}) => {
const message = useAtomValue(currentStreamingMessageAtom);
return message?.text && message?.text?.length > 0 ? (
<div className="flex items-start gap-2 ml-3">
<Image
className="rounded-full"
src={avatarUrl}
width={32}
height={32}
alt=""
/>
<div className="flex flex-col gap-1 w-full">
<div className="flex gap-1 justify-start items-baseline">
<div className="text-[#1B1B1B] text-sm font-extrabold leading-[15.2px] dark:text-[#d1d5db]">
{senderName}
</div>
<div className="text-xs leading-[13.2px] font-medium text-gray-400">
{displayDate(createdAt)}
</div>
</div>
{message.text.includes("```") ? (
getMessageCode(message.text).map((item, i) => (
<div className="flex gap-1 flex-col" key={i}>
<p className="leading-[20px] whitespace-break-spaces text-sm font-normal dark:text-[#d1d5db]">
{item.text}
</p>
{item.code.trim().length > 0 && <TextCode text={item.code} />}
</div>
))
) : (
<p
className="leading-[20px] whitespace-break-spaces text-sm font-normal dark:text-[#d1d5db]"
dangerouslySetInnerHTML={{ __html: message.text }}
/>
)}
</div>
</div>
) : (
<></>
);
};
export default React.memo(StreamTextMessage);

View File

@ -1,34 +0,0 @@
"use client";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import Image from "next/image";
type Props = {
text: string;
};
export const TextCode: React.FC<Props> = ({ text }) => (
<div className="w-full rounded-lg overflow-hidden bg-[#1F2A37] mr-3">
<div className="text-gray-200 bg-gray-800 flex items-center justify-between px-4 py-2 text-xs capitalize">
<button onClick={() => navigator.clipboard.writeText(text)}>
<Image
src={"icons/unicorn_clipboard-alt.svg"}
width={24}
height={24}
alt=""
/>
</button>
</div>
{/*
// @ts-ignore */}
<SyntaxHighlighter
className="w-full overflow-x-hidden resize-none"
language="jsx"
style={atomOneDark}
customStyle={{ padding: "12px", background: "transparent" }}
wrapLongLines={true}
>
{text}
</SyntaxHighlighter>
</div>
);

View File

@ -8,7 +8,7 @@ const UserToolbar: React.FC = () => {
const currentConvo = useAtomValue(currentConversationAtom);
const avatarUrl = currentConvo?.image;
const title = currentConvo?.name ?? "";
const title = currentConvo?.summary ?? currentConvo?.name ?? "";
return (
<div className="flex items-center gap-3 p-1">

View File

@ -103,7 +103,3 @@ export const updateLastMessageAsReadyAtom = atom(
set(chatMessages, newData);
}
);
export const currentStreamingMessageAtom = atom<ChatMessage | undefined>(
undefined
);

View File

@ -33,7 +33,7 @@ export const currentConvoStateAtom = atom<ConversationState | undefined>(
(get) => {
const activeConvoId = get(activeConversationIdAtom);
if (!activeConvoId) {
console.log("active convo id is undefined");
console.debug("Active convo id is undefined");
return undefined;
}
@ -80,6 +80,29 @@ export const updateConversationHasMoreAtom = atom(
}
);
export const updateConversationAtom = atom(
null,
(get, set, conversation: Conversation) => {
const id = conversation._id;
if (!id) return;
const convo = get(userConversationsAtom).find((c) => c._id === id);
if (!convo) return;
const newConversations: Conversation[] = get(userConversationsAtom).map(
(c) => (c._id === id ? conversation : c)
);
// sort new conversations based on updated at
newConversations.sort((a, b) => {
const aDate = new Date(a.updatedAt ?? 0);
const bDate = new Date(b.updatedAt ?? 0);
return bDate.getTime() - aDate.getTime();
});
set(userConversationsAtom, newConversations);
}
);
/**
* Stores all conversations for the current user
*/
@ -87,30 +110,3 @@ export const userConversationsAtom = atom<Conversation[]>([]);
export const currentConversationAtom = atom<Conversation | undefined>((get) =>
get(userConversationsAtom).find((c) => c._id === get(getActiveConvoIdAtom))
);
export const setConvoUpdatedAtAtom = atom(null, (get, set, convoId: string) => {
const convo = get(userConversationsAtom).find((c) => c._id === convoId);
if (!convo) return;
const newConvo: Conversation = {
...convo,
updatedAt: new Date().toISOString(),
};
const newConversations: Conversation[] = get(userConversationsAtom).map((c) =>
c._id === convoId ? newConvo : c
);
set(userConversationsAtom, newConversations);
});
export const setConvoLastImageAtom = atom(
null,
(get, set, convoId: string, lastImageUrl: string) => {
const convo = get(userConversationsAtom).find((c) => c._id === convoId);
if (!convo) return;
const newConvo: Conversation = { ...convo };
const newConversations: Conversation[] = get(userConversationsAtom).map(
(c) => (c._id === convoId ? newConvo : c)
);
set(userConversationsAtom, newConversations);
}
);

View File

@ -0,0 +1,6 @@
import { atom } from "jotai";
/**
* Stores expand state of conversation container. Default is true.
*/
export const leftSideBarExpandStateAtom = atom<boolean>(true);

View File

@ -1,75 +0,0 @@
export default function useGetModelApiInfo() {
const data = [
{
type: "cURL",
stringCode: `import SyntaxHighlighter from 'react-syntax-highlighter';
import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs';
const Component = () => {
const codeString = '(num) => num + 1';
return (
<SyntaxHighlighter language="javascript" style={docco}>
{codeString}
</SyntaxHighlighter>
);
};`,
},
{
type: "Python",
stringCode: `# This function adds two numbers
def add(x, y):
return x + y
# This function subtracts two numbers
def subtract(x, y):
return x - y
# This function multiplies two numbers
def multiply(x, y):
return x * y
# This function divides two numbers
def divide(x, y):
return x / y
print("Select operation.")
print("1.Add")
print("2.Subtract")
print("3.Multiply")
print("4.Divide")`,
},
{
type: "Node",
stringCode: `var express = require('express');
var app = express();
app.get('/', function (req, res) {
res.send('Hello World');
})
var server = app.listen(8081, function () {
var host = server.address().address
var port = server.address().port
})
`,
},
{
type: "JSON",
stringCode: `{"menu": {
"id": "file",
"value": "File",
"popup": {
"menuitem": [
{"value": "New", "onclick": "CreateNewDoc()"},
{"value": "Open", "onclick": "OpenDoc()"},
{"value": "Close", "onclick": "CloseDoc()"}
]
}
}}`,
},
];
return {
data,
};
}

View File

@ -13,12 +13,17 @@ export default function useInitModel() {
return;
}
const currentTime = Date.now();
console.debug("Init model: ", model._id);
const res = await executeSerial(InferenceService.InitModel, model._id);
if (res?.error) {
console.log("error occured: ", res);
return res;
} else {
console.log(`Init model successfully!`);
console.debug(
`Init model successfully!, take ${Date.now() - currentTime}ms`
);
setActiveModel(model);
return {};
}

View File

@ -3,6 +3,7 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { DataService, InferenceService } from "@janhq/plugin-core";
import {
ChatMessage,
MessageSenderType,
RawMessage,
toChatMessage,
@ -13,17 +14,19 @@ import {
addNewMessageAtom,
updateMessageAtom,
chatMessages,
currentStreamingMessageAtom,
currentChatMessagesAtom,
} from "@/_helpers/atoms/ChatMessage.atom";
import {
currentConversationAtom,
getActiveConvoIdAtom,
updateConversationAtom,
updateConversationWaitingForResponseAtom,
} from "@/_helpers/atoms/Conversation.atom";
import { Conversation } from "@/_models/Conversation";
export default function useSendChatMessage() {
const currentConvo = useAtomValue(currentConversationAtom);
const updateStreamMessage = useSetAtom(currentStreamingMessageAtom);
const updateConversation = useSetAtom(updateConversationAtom);
const addNewMessage = useSetAtom(addNewMessageAtom);
const updateMessage = useSetAtom(updateMessageAtom);
const activeConversationId = useAtomValue(getActiveConvoIdAtom) ?? "";
@ -100,7 +103,7 @@ export default function useSendChatMessage() {
};
const respId = await executeSerial(DataService.CreateMessage, newResponse);
newResponse._id = respId;
const responseChatMessage = await toChatMessage(newResponse);
const responseChatMessage = toChatMessage(newResponse);
addNewMessage(responseChatMessage);
while (true && reader) {
@ -119,10 +122,6 @@ export default function useSendChatMessage() {
if (answer.startsWith("assistant: ")) {
answer = answer.replace("assistant: ", "");
}
updateStreamMessage({
...responseChatMessage,
text: answer,
});
updateMessage(
responseChatMessage.id,
responseChatMessage.conversationId,
@ -144,8 +143,103 @@ export default function useSendChatMessage() {
.replace("T", " ")
.replace(/\.\d+Z$/, ""),
});
const updatedConvo: Conversation = {
...currentConvo,
lastMessage: answer.trim(),
updatedAt: new Date().toISOString(),
};
await executeSerial(DataService.UpdateConversation, updatedConvo);
updateConversation(updatedConvo);
updateConvWaiting(conversationId, false);
newResponse.message = answer.trim();
const messages: RawMessage[] = [newMessage, newResponse];
inferConvoSummary(updatedConvo, messages);
};
const inferConvoSummary = async (
convo: Conversation,
lastMessages: RawMessage[]
) => {
if (convo.summary) return;
const newMessage: RawMessage = {
conversationId: currentConvo?._id,
message: "summary this conversation in 5 words",
user: "user",
createdAt: new Date().toISOString(),
};
const messageHistory = lastMessages.map((m) => toChatMessage(m));
const newChatMessage = toChatMessage(newMessage);
const recentMessages = [...messageHistory, newChatMessage]
.slice(-10)
.map((message) => ({
content: message.text,
role: message.messageSenderType,
}));
console.debug(`Sending ${JSON.stringify(recentMessages)}`);
const url = await executeSerial(InferenceService.InferenceUrl);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
"Access-Control-Allow-Origi": "*",
},
body: JSON.stringify({
messages: recentMessages,
stream: true,
model: "gpt-3.5-turbo",
max_tokens: 500,
}),
});
const stream = response.body;
const decoder = new TextDecoder("utf-8");
const reader = stream?.getReader();
let answer = "";
// Cache received response
const newResponse: RawMessage = {
conversationId: currentConvo?._id,
message: answer,
user: "assistant",
createdAt: new Date().toISOString(),
};
while (reader) {
const { done, value } = await reader.read();
if (done) {
console.log("SSE stream closed");
break;
}
const text = decoder.decode(value);
const lines = text.trim().split("\n");
for (const line of lines) {
if (line.startsWith("data: ") && !line.includes("data: [DONE]")) {
const data = JSON.parse(line.replace("data: ", ""));
answer += data.choices[0]?.delta?.content ?? "";
if (answer.startsWith("assistant: ")) {
answer = answer.replace("assistant: ", "");
}
}
}
}
const updatedConvo: Conversation = {
...convo,
summary: answer.trim(),
};
console.debug(`Update convo: ${JSON.stringify(updatedConvo)}`);
await executeSerial(DataService.UpdateConversation, updatedConvo);
updateConversation(updatedConvo);
};
return {
sendChatMessage,
};

View File

@ -1,6 +1,3 @@
import { remark } from "remark";
import html from "remark-html";
export enum MessageType {
Text = "Text",
Image = "Image",
@ -9,8 +6,8 @@ export enum MessageType {
}
export enum MessageSenderType {
Ai = "Ai",
User = "User",
Ai = "assistant",
User = "user",
}
export enum MessageStatus {
@ -26,7 +23,6 @@ export interface ChatMessage {
senderUid: string;
senderName: string;
senderAvatarUrl: string;
htmlText?: string | undefined;
text: string | undefined;
imageUrls?: string[] | undefined;
createdAt: number;
@ -42,7 +38,7 @@ export interface RawMessage {
updatedAt?: string;
}
export const toChatMessage = async (m: RawMessage): Promise<ChatMessage> => {
export const toChatMessage = (m: RawMessage): ChatMessage => {
const createdAt = new Date(m.createdAt ?? "").getTime();
const imageUrls: string[] = [];
const imageUrl = undefined;
@ -55,8 +51,6 @@ export const toChatMessage = async (m: RawMessage): Promise<ChatMessage> => {
m.user === "user" ? MessageSenderType.User : MessageSenderType.Ai;
const content = m.message ?? "";
const processedContent = await remark().use(html).process(content);
const contentHtml = processedContent.toString();
return {
id: (m._id ?? 0).toString(),
@ -68,7 +62,6 @@ export const toChatMessage = async (m: RawMessage): Promise<ChatMessage> => {
senderAvatarUrl:
m.user === "user" ? "icons/avatar.svg" : "icons/app_icon.svg",
text: content,
htmlText: contentHtml,
imageUrls: imageUrls,
createdAt: createdAt,
status: MessageStatus.Ready,

View File

@ -4,6 +4,8 @@ export interface Conversation {
name?: string;
image?: string;
message?: string;
lastMessage?: string;
summary?: string;
createdAt?: string;
updatedAt?: string;
}

View File

@ -25,18 +25,3 @@ export function mergeAndRemoveDuplicates(
return result.reverse();
}
export function getMessageCode(stringCode: string) {
const blocks = stringCode.split("```");
const resultArray = [];
for (let i = 0; i < blocks.length; i += 2) {
const text = blocks[i] ? blocks[i].trim() : "";
const code = blocks[i + 1] ? blocks[i + 1].trim() : "";
if (text || code) {
resultArray.push({ text, code });
}
}
return resultArray;
}

97
web/app/code-block.css Normal file
View File

@ -0,0 +1,97 @@
.hljs-comment,
.hljs-quote {
color: #d4d0ab;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #ffa07a;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #f5ab35;
}
/* Yellow */
.hljs-attribute {
color: #ffd700;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #abe338;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #00e0e0;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #dcc6e0;
}
.hljs {
display: block;
overflow-x: auto;
background: #2b2b2b;
color: #f8f8f2;
padding: 0.5em;
border-radius: 0.4rem;
margin-top: 1rem;
margin-bottom: 1rem;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
@media screen and (-ms-high-contrast: active) {
.hljs-addition,
.hljs-attribute,
.hljs-built_in,
.hljs-builtin-name,
.hljs-bullet,
.hljs-comment,
.hljs-link,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-params,
.hljs-string,
.hljs-symbol,
.hljs-type,
.hljs-quote {
color: highlight;
}
.hljs-keyword,
.hljs-selector-tag {
font-weight: bold;
}
}

View File

@ -3,6 +3,7 @@
@tailwind utilities;
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@import "./code-block.css";
:root {
/* Your default theme */

View File

@ -2,11 +2,9 @@
import { ThemeWrapper } from "./_helpers/ThemeWrapper";
import JotaiWrapper from "./_helpers/JotaiWrapper";
import RightContainer from "./_components/RightContainer";
import { ModalWrapper } from "./_helpers/ModalWrapper";
import { useEffect, useState } from "react";
import Image from "next/image";
import {
setup,
plugins,
@ -16,9 +14,9 @@ import {
isCorePluginInstalled,
setupBasePlugins,
} from "./_services/pluginService";
import LeftContainer from "./_components/LeftContainer";
import EventListenerWrapper from "./_helpers/EventListenerWrapper";
import { setupCoreServices } from "./_services/coreService";
import MainContainer from "./_components/MainContainer";
const Page: React.FC = () => {
const [activated, setActivated] = useState(false);
@ -63,13 +61,9 @@ const Page: React.FC = () => {
<EventListenerWrapper>
<ThemeWrapper>
<ModalWrapper>
{activated && (
<div className="flex">
<LeftContainer />
<RightContainer />
</div>
)}
{!activated && (
{activated ? (
<MainContainer />
) : (
<div className="bg-white w-screen h-screen items-center justify-center flex">
<Image width={50} height={50} src="icons/app_icon.svg" alt="" />
</div>

View File

@ -17,16 +17,19 @@
"@tailwindcss/typography": "^0.5.9",
"@types/react": "18.2.15",
"@types/react-dom": "18.2.7",
"@types/react-syntax-highlighter": "^15.5.7",
"autoprefixer": "10.4.14",
"classnames": "^2.3.2",
"embla-carousel": "^8.0.0-rc11",
"embla-carousel-react": "^8.0.0-rc11",
"eslint": "8.45.0",
"eslint-config-next": "13.4.10",
"framer-motion": "^10.16.4",
"highlight.js": "^11.9.0",
"jotai": "^2.4.0",
"jotai-optics": "^0.3.1",
"jwt-decode": "^3.1.2",
"marked": "^9.1.2",
"marked-highlight": "^2.0.6",
"next": "13.4.10",
"next-auth": "^4.23.1",
"next-themes": "^0.2.1",
@ -35,9 +38,6 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.4",
"react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.3",
"remark-html": "^15.0.2",
"tailwindcss": "3.3.3",
"typescript": "5.1.6"
},

11231
yarn.lock

File diff suppressed because it is too large Load Diff