This commit is contained in:
Cledwyn Lew
2025-12-25 15:10:20 +08:00
parent 8f084dc75d
commit a41bf16972
16 changed files with 606 additions and 849 deletions

View File

@@ -25,6 +25,11 @@
"quoteStyle": "single"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [
{
"includes": ["**/*.{ts,tsx,js,jsx}"],
@@ -319,4 +324,3 @@
}
}
}

View File

@@ -18,11 +18,10 @@
"dependencies": {
"@heroui/react": "2.8.3",
"@heroui/theme": "2.4.21",
"@mujian/js-sdk": "0.0.6-beta.41",
"@mujian/js-sdk": "0.0.6-beta.56",
"@tailwindcss/vite": "4.1.12",
"ahooks": "3.9.5",
"axios": "1.11.0",
"biome": "^0.3.3",
"dayjs": "1.11.18",
"lodash-es": "4.17.21",
"lucide-react": "^0.546.0",
@@ -37,6 +36,7 @@
"zustand": "5.0.8"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@mujian/cli": "0.0.0",
"@types/lodash-es": "4.17.12",
"@types/node": "22.13.9",

854
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { Button, ButtonGroup, cn } from '@heroui/react';
import { mjChatCls } from '@/utils/cls';
export type EditActionsProps = {
isUser: boolean;
@@ -17,26 +18,38 @@ export const EditActions = ({
return (
<div
className={cn(
className={mjChatCls(
['msg-edit-actions-wrapper'],
cn(
`flex w-full row-start-2 col-start-1 col-end-4`,
isUser ? 'justify-end' : 'justify-start',
),
)}
>
<ButtonGroup
className="bg-white/15 text-default rounded-full"
className={mjChatCls(
['msg-edit-actions-button-group'],
'bg-white/15 text-default rounded-full',
)}
radius="full"
size="sm"
variant="light"
>
<Button
className="text-default"
className={mjChatCls(
['msg-edit-actions-button', 'msg-edit-actions-button-save'],
'text-default',
)}
size="sm"
onPress={onSaveEdit}
>
</Button>
<Button
className="text-default"
className={mjChatCls(
['msg-edit-actions-button', 'msg-edit-actions-button-cancel'],
'text-default',
)}
size="sm"
onPress={onCancelEdit}
>

View File

@@ -9,6 +9,7 @@ import {
} from '@heroui/react';
import { ChevronLeftIcon, ChevronRightIcon, Ellipsis } from 'lucide-react';
import React from 'react';
import { mjChatCls } from '@/utils/cls';
import { useMessageActions } from './useMessageActions';
export type LastMessageActionsProps = {
@@ -22,6 +23,7 @@ export type LastMessageActionsProps = {
onRegenerate: (() => Promise<void>) | undefined;
onContinue: (() => Promise<void>) | undefined;
onEditButtonClick: () => void;
onCopyClick: () => void;
onSwipe: (direction: 'prev' | 'next') => void;
onMessageTextClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
onDelete: () => void;
@@ -38,6 +40,7 @@ export const MessageActions = ({
onRegenerate,
onContinue,
onEditButtonClick,
onCopyClick,
onSwipe,
onMessageTextClick,
onDelete,
@@ -45,6 +48,7 @@ export const MessageActions = ({
const dropdownItems = useMessageActions({
isLastMsg,
onEditButtonClick,
onCopyClick,
onRegenerate,
onContinue,
onDelete,
@@ -54,31 +58,82 @@ export const MessageActions = ({
return (
<div
className={cn(
'message-actions flex justify-between row-start-2 col-start-1 col-end-4',
className={mjChatCls(
['msg-actions-wrapper'],
cn('flex justify-between row-start-2 col-start-1 col-end-4'),
)}
role="presentation"
onClick={onMessageTextClick} /* Stop propagation */
>
<div className="flex items-start">
<div
className={mjChatCls(
['msg-actions-dropdown-container'],
'flex items-start',
)}
>
{!isFirstMsg && (
<Dropdown placement="top-start" className="dark" size="sm">
<Dropdown
placement="top-start"
className={mjChatCls(['msg-actions-dropdown'], 'dark')}
size="sm"
>
<DropdownTrigger>
<Button
variant="light"
size="sm"
className="bg-white/15 text-default min-w-10"
className={mjChatCls(
['msg-actions-dropdown-button'],
'bg-white/15 text-default min-w-10',
)}
>
<Ellipsis size={14}/>
<Ellipsis
size={14}
className={mjChatCls(['msg-actions-dropdown-button-icon'])}
/>
</Button>
</DropdownTrigger>
<DropdownMenu items={dropdownItems}>
<DropdownMenu
items={dropdownItems}
className={mjChatCls(
['msg-actions-dropdown-menu'],
'text-[initial]',
)}
>
{(item) => (
<DropdownItem
key={item.key}
color={item.color}
className={item.className}
className={mjChatCls(
[
'msg-actions-dropdown-menu-item',
`msg-actions-dropdown-menu-item-${item.key}`,
],
item.className || '',
)}
onPress={item.onPress}
classNames={{
wrapper: mjChatCls(
[
'msg-actions-dropdown-menu-item-wrapper',
`msg-actions-dropdown-menu-item-${item.key}-wrapper`,
],
item.className || '',
),
base: mjChatCls(
[
'msg-actions-dropdown-menu-item-base',
`msg-actions-dropdown-menu-item-${item.key}-base`,
],
item.className || '',
),
title: mjChatCls(
[
'msg-actions-dropdown-menu-item-title',
`msg-actions-dropdown-menu-item-${item.key}-title`,
],
item.className || '',
),
}}
>
{item.label}
</DropdownItem>
@@ -87,34 +142,52 @@ export const MessageActions = ({
</Dropdown>
)}
</div>
<div className="flex items-end">
<div className={mjChatCls(['swipe-action-container'], 'flex items-end')}>
{isLastMsg && swipes.length > 1 && (
<ButtonGroup
className="bg-white/15 rounded-full backdrop-blur-sm text-default"
className={mjChatCls(
['swipe-action-button-group'],
'bg-white/15 rounded-full backdrop-blur-sm text-default',
)}
radius="full"
size="sm"
variant="light"
>
<Button
className="min-w-8 px-2 text-default"
className={mjChatCls(
['swipe-action-prev'],
'min-w-8 px-2 text-default',
)}
isDisabled={swipes.length <= 1}
size="sm"
onPress={() => onSwipe('prev')}
>
<ChevronLeftIcon className="w-4 h-4" />
<ChevronLeftIcon
className={mjChatCls(['swipe-action-prev-icon'], 'w-4 h-4')}
/>
</Button>
<div className="flex items-center px-1 text-xs">
<div
className={mjChatCls(
['swipe-action-index'],
'flex items-center px-1 text-xs',
)}
>
{swipes.length > 0
? `${activeSwipeId + 1}/${swipes.length}`
: '1/1'}
</div>
<Button
className="min-w-8 px-2 text-default"
className={mjChatCls(
['swipe-action-next'],
'min-w-8 px-2 text-default',
)}
isDisabled={swipes.length <= 1}
size="sm"
onPress={() => onSwipe('next')}
>
<ChevronRightIcon className="w-4 h-4" />
<ChevronRightIcon
className={mjChatCls(['swipe-action-next-icon'], 'w-4 h-4')}
/>
</Button>
</ButtonGroup>
)}

View File

@@ -7,6 +7,7 @@ import {
Spinner,
} from '@heroui/react';
import { type Message as BaseMessage, MdRenderer } from '@mujian/js-sdk/react';
import { mjChatCls } from '@/utils/cls';
import { MessageEditArea } from './MessageEditArea';
import { useMessageActions } from './useMessageActions';
@@ -19,6 +20,7 @@ export type MessageBubbleProps = {
isStreaming: boolean;
currentMessage: string;
onEditButtonClick: () => void;
onCopyClick: () => void;
onDelete: () => void;
onEditAreaShow: () => void;
};
@@ -31,44 +33,72 @@ export const MessageBubble = ({
isStreaming,
currentMessage,
onEditButtonClick,
onCopyClick,
onDelete,
onEditAreaShow,
}: MessageBubbleProps) => {
const dropdownItems = useMessageActions({
isLastMsg: false,
onEditButtonClick,
onCopyClick,
onDelete,
});
return (
<div
className={cn(
'row-start-1 col-start-1 col-end-4 flex flex-col size-full min-w-0 message-wrapper',
className={mjChatCls(
[
'msg-container-wrapper',
isUser
? 'msg-container-wrapper-user'
: 'msg-container-wrapper-assistant',
],
cn(
'row-start-1 col-start-1 col-end-4 flex flex-col size-full min-w-0 py-3',
isUser ? 'items-end' : 'items-stretch',
),
)}
>
{!isEditing ? (
isUser ? (
<Dropdown placement="top-end" className="dark">
<Dropdown
placement="top-end"
className={mjChatCls(['msg-edit-dropdown'], 'dark')}
>
<DropdownTrigger>
<div
className={cn(
className={mjChatCls(
['msg-edit-trigger'],
cn(
'h-full rounded-xl dark:prose-invert text-blue-100 p-4 bg-black/50',
{
'rounded-br-sm': isUser,
},
),
)}
role="presentation"
>
<MdRenderer content={currentMessage} />
</div>
</DropdownTrigger>
<DropdownMenu items={dropdownItems}>
<DropdownMenu
items={dropdownItems}
className={mjChatCls(
['msg-edit-dropdown-menu'],
'text-[initial]',
)}
>
{(item) => (
<DropdownItem
key={item.key}
color={item.color}
className={item.className}
className={mjChatCls(
[
'msg-edit-dropdown-menu-item',
`msg-edit-dropdown-menu-item-${item.key}`,
],
item.className || '',
)}
onPress={item.onPress}
>
{item.label}
@@ -77,9 +107,9 @@ export const MessageBubble = ({
</DropdownMenu>
</Dropdown>
) : (
<div className="py-4">
// <div className={mjChatCls(['msg-content-wrapper'], "py-4")}>
<MdRenderer content={currentMessage} />
</div>
// </div>
)
) : (
<MessageEditArea
@@ -90,7 +120,12 @@ export const MessageBubble = ({
/>
)}
{isStreaming && !currentMessage?.length && (
<div className="flex w-full items-center">
<div
className={mjChatCls(
['msg-container-message-spinner'],
'flex w-full items-center',
)}
>
<Spinner variant="dots" />
</div>
)}

View File

@@ -1,5 +1,6 @@
import { cn } from '@heroui/react';
import { useEffect, useRef, useState } from 'react';
import { mjChatCls } from '@/utils/cls';
export type MessageEditAreaProps = {
isUser: boolean;
@@ -39,16 +40,22 @@ export const MessageEditArea = ({
return (
<div
className="w-full"
className={mjChatCls(['msg-edit-textarea-wrapper'], 'w-full')}
style={{
height: wrapHeight,
}}
>
<textarea
ref={textareaRef}
className={cn(
className={mjChatCls(
[
'msg-edit-textarea',
isUser ? 'msg-edit-textarea-user' : 'msg-edit-textarea-assistant',
],
cn(
'w-full outline-none resize-none h-fit overflow-hidden p-4 bg-background rounded-xl text-[16px]',
isUser ? 'rounded-br-sm' : 'rounded-bl-sm',
),
)}
value={editedMessage}
onChange={(e) => {

View File

@@ -15,6 +15,7 @@ export type DropdownItem = {
export type UseDropdownItemsProps = {
isLastMsg: boolean;
onEditButtonClick: () => void;
onCopyClick: () => void;
onRegenerate?: (() => Promise<void>) | undefined;
onContinue?: (() => Promise<void>) | undefined;
onDelete: () => void;
@@ -23,6 +24,7 @@ export type UseDropdownItemsProps = {
export const useMessageActions = ({
isLastMsg,
onEditButtonClick,
onCopyClick,
onRegenerate,
onContinue,
onDelete,
@@ -33,6 +35,11 @@ export const useMessageActions = ({
label: '编辑',
onPress: onEditButtonClick,
},
{
key: 'copy',
label: '复制',
onPress: onCopyClick,
},
...(isLastMsg
? [
{
@@ -58,4 +65,3 @@ export const useMessageActions = ({
return dropdownItems;
};

View File

@@ -1,6 +1,7 @@
import { cn } from '@heroui/react';
import { type Message as BaseMessage } from '@mujian/js-sdk/react';
import { addToast, cn } from '@heroui/react';
import { type Message as BaseMessage, useMujian } from '@mujian/js-sdk/react';
import React, { type MouseEventHandler } from 'react';
import { mjChatCls } from '@/utils/cls';
import { EditActions } from './components/EditActions';
import { MessageActions } from './components/MessageActions';
import { MessageBubble } from './components/MessageBubble';
@@ -89,6 +90,7 @@ export const MessageItem = React.memo((props: MessageItemProps) => {
const messageRef = React.useRef<HTMLDivElement>(null); // 使用 useRef 获取当前组件的引用
const [isEditing, setIsEditing] = React.useState(false); // State to toggle edit mode
const [editedMessage, setEditedMessage] = React.useState(content);
const mujian = useMujian();
const renderedMessage = isUser ? content : swipes[activeSwipeId];
@@ -114,6 +116,22 @@ export const MessageItem = React.memo((props: MessageItemProps) => {
}
};
const handleCopy = async () => {
try {
await mujian.utils.clipboard.writeText(originalMessage.content);
addToast({
color: 'success',
description: '复制成功',
timeout: 1500,
});
} catch (error) {
console.error(error);
addToast({
description: '复制失败,请重试。',
});
}
};
// Separate function to handle message deletion
const handleDeleteMessage = async () => {
if (id) {
@@ -157,14 +175,15 @@ export const MessageItem = React.memo((props: MessageItemProps) => {
<div
key={`message-${activeSwipeId}`}
ref={messageRef} // 将 ref 绑定到根容器
className={cn(
'grid items-end gap-2 mb-2 w-full grid-cols-[32px_1fr_32px]',
className={mjChatCls(
['msg-item-wrapper'],
cn('grid items-end gap-2 mb-2 w-full grid-cols-[32px_1fr_32px]'),
)}
onContextMenu={(event) => {
// 按ctrl点击时打印ID
if (event.ctrlKey) {
event.preventDefault();
console.log('Message ID:', id);
console.log('Official Chat Message ID:', id);
}
}}
>
@@ -174,6 +193,7 @@ export const MessageItem = React.memo((props: MessageItemProps) => {
isEditing={isEditing}
editedMessage={editedMessage}
onEditChange={setEditedMessage}
onCopyClick={handleCopy}
isStreaming={isStreaming}
currentMessage={renderedMessage}
onEditButtonClick={handleEditButtonClick}
@@ -206,6 +226,7 @@ export const MessageItem = React.memo((props: MessageItemProps) => {
onRegenerate={onRegenerate}
onContinue={onContinue}
onEditButtonClick={handleEditButtonClick}
onCopyClick={handleCopy}
onSwipe={handleSwipe}
onMessageTextClick={handleMessageTextClick}
onDelete={handleDeleteMessage}

View File

@@ -1,5 +1,6 @@
import type { Message as MessageType } from '@mujian/js-sdk/react';
import { Thread, type VListHandle } from '@mujian/js-sdk/react';
import { throttle } from 'lodash-es';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useGlobalStore } from '@/store/global';
import { MessageItem } from './MessageItem/';
@@ -12,6 +13,7 @@ export type MsgProps = {
onDelete: (messageId: string) => Promise<void>;
onEdit: (messageId: string, content: string) => Promise<void>;
onSwipe: (messageId: string, swipeId: number) => Promise<void>;
onNeedMore: () => Promise<void>;
};
const OVERSCAN = 10;
@@ -25,11 +27,13 @@ export const MessageList = React.memo(
onDelete,
onEdit,
onSwipe,
onNeedMore,
}: MsgProps) => {
const prevLengthRef = useRef(0);
const prevLastMesRef = useRef<string | undefined>(undefined);
const { activePersona, projectInfo } = useGlobalStore();
const vListRef = useRef<VListHandle>(null);
const [visibleRange, setVisibleRange] = useState<[number, number]>([0, 0]);
const [shifting, setShifting] = useState(false);
useEffect(() => {
const vl = vListRef.current;
@@ -38,22 +42,24 @@ export const MessageList = React.memo(
}
const isScrolledBottom = () =>
vl.viewportSize + vl.scrollOffset + 16 >= vl.scrollSize;
const isFirstLoading = prevLengthRef.current === 0;
const lastMessage = data[data.length - 1];
const shouldScroll = [
isFirstLoading, // 首次加载
data.length > prevLengthRef.current, // 新消息
data[data.length - 1]?.isStreaming && isScrolledBottom(), // 正在生成
lastMessage?.id !== prevLastMesRef.current, // 新消息
lastMessage?.isStreaming && isScrolledBottom(), // 正在生成
].some(Boolean);
if (shouldScroll) {
vListRef.current?.scrollTo(isFirstLoading ? 99999999 : vl.scrollSize);
vListRef.current?.scrollTo(
prevLastMesRef.current ? vl.scrollSize : 9999999,
);
}
prevLengthRef.current = data.length;
prevLastMesRef.current = lastMessage?.id;
}, [data]);
const onScroll = () => {
const onScroll = throttle(
() => {
const vl = vListRef.current;
if (!vl) {
return;
@@ -62,7 +68,22 @@ export const MessageList = React.memo(
const startIndex = vl.findStartIndex();
const endIndex = vl.findEndIndex();
setVisibleRange([startIndex, endIndex]);
};
if (startIndex === 0) {
// 让VList计算元素相对底部的距离加载完毕后再切回顶部距离
setShifting(true);
onNeedMore().finally(() => {
setTimeout(() => {
setShifting(false);
}, 200);
});
}
},
300,
{
leading: false,
},
);
const keepMountedList = useMemo(() => {
if (visibleRange[0] === 0 && visibleRange[1] === 0) {
@@ -88,6 +109,7 @@ export const MessageList = React.memo(
overscan: OVERSCAN,
keepMounted: keepMountedList,
onScroll,
shift: shifting,
}}
>
{(props) => (

View File

@@ -1,5 +1,6 @@
import { Button, Textarea } from '@heroui/react';
import { CircleStopIcon, SendIcon } from 'lucide-react';
import { mjChatCls } from '@/utils/cls';
interface Props {
onSend: (query: string) => void;
@@ -39,46 +40,74 @@ export const MsgSend = ({
return (
<>
<Textarea
className="dark"
className={mjChatCls(['input-textarea'], 'dark')}
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
label: mjChatCls(
['input-textarea-label'],
'text-black/50 dark:text-white/90',
),
input: mjChatCls(
['input-textarea-input'],
[
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60',
],
innerWrapper: 'bg-transparent items-center',
inputWrapper: ['shadow-xl', 'bg-dark/40', 'dark:bg-black/40'],
mainWrapper: 'px-2',
].join(' '),
),
innerWrapper: mjChatCls(
['input-textarea-innerWrapper'],
'bg-transparent items-center',
),
inputWrapper: mjChatCls(
['input-textarea-inputWrapper'],
'shadow-xl bg-dark/40 dark:bg-black/40',
),
mainWrapper: mjChatCls(['input-textarea-mainWrapper'], 'px-2'),
}}
disabled={running}
endContent={
running ? (
<Button
className={mjChatCls(
['input-textarea-stop-button'],
'bg-primary',
)}
isIconOnly
color="primary"
radius="full"
size="sm"
onPress={() => {
onStop();
}}
>
<CircleStopIcon className="w-5 h-5" />
<CircleStopIcon
className={mjChatCls(
['input-textarea-stop-button-icon'],
'w-5 h-5',
)}
/>
</Button>
) : (
<Button
className={mjChatCls(
['input-textarea-send-button'],
'bg-primary',
)}
isIconOnly
color="primary"
radius="full"
size="sm"
onPress={handleSend}
isDisabled={isEmptyInput}
>
<SendIcon className="w-5 h-5" />
<SendIcon
className={mjChatCls(
['input-textarea-send-button-icon'],
'w-5 h-5',
)}
/>
</Button>
)
}
maxLength={1000}
maxLength={50000}
maxRows={4}
minRows={1}
placeholder={running ? '正在生成...' : '按 Enter 发送'}

View File

@@ -0,0 +1,115 @@
import { Chip, ScrollShadow } from '@heroui/react';
import type { QuickReply as QuickReplyType } from '@mujian/js-sdk/types';
import { useEffect, useRef } from 'react';
import { mjChatCls } from '@/utils/cls';
export interface QuickReplyProps {
quickReplies: Array<QuickReplyType>;
onSend: (query: string) => void;
}
export const QuickReply = (props: QuickReplyProps) => {
const { quickReplies, onSend } = props;
const scrollShadowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaY) > 0 && scrollShadowRef.current) {
e.preventDefault();
scrollShadowRef.current.scrollLeft += e.deltaY;
}
};
scrollShadowRef?.current?.addEventListener('wheel', handleWheel, {
passive: false,
});
return () =>
scrollShadowRef?.current?.removeEventListener('wheel', handleWheel);
}, []);
const lastDragTime = useRef(0);
const onmousedown = (e: React.MouseEvent<HTMLDivElement>) => {
// 防止文字选择
e.preventDefault();
// 禁用页面文字选择
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
lastDragTime.current = 0;
let dragged = false;
// biome-ignore lint/suspicious/noExplicitAny: 抄来的代码
const mouseMove: EventListener = (e: any) => {
dragged = true;
if (scrollShadowRef.current) {
scrollShadowRef.current.scrollLeft += e.movementX * -2;
}
};
const mouseUp: EventListener = () => {
// 恢复文字选择
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseUp);
if (dragged) {
lastDragTime.current = Date.now();
}
};
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseUp);
};
return (
<ScrollShadow
ref={scrollShadowRef}
hideScrollBar
className={mjChatCls(
['input-quickreply-container'],
'flex w-full overflow-auto gap-2',
)}
orientation="horizontal"
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
}}
onMouseDown={onmousedown}
>
{quickReplies.map((qr, index) => (
<Chip
key={index}
className={mjChatCls(
['input-quickreply-chip', `input-quickreply-chip-${index + 1}`],
'bg-white/15 text-default shrink-0 cursor-pointer',
)}
classNames={{
base: mjChatCls([
'input-quickreply-chip-base',
`input-quickreply-chip-${index + 1}-base`,
]),
content: mjChatCls([
'input-quickreply-chip-content',
`input-quickreply-chip-${index + 1}-content`,
]),
}}
radius="full"
variant="light"
onClick={() => {
const isClick = Date.now() - lastDragTime.current > 50;
if (isClick && qr.message) {
onSend(qr.message);
}
}}
>
{qr.label}
</Chip>
))}
</ScrollShadow>
);
};

View File

@@ -1,10 +1,12 @@
import { Alert, ScrollShadow, Spinner } from '@heroui/react';
import { Alert, ScrollShadow, Spinner, ToastProvider } from '@heroui/react';
import { useChat, useMujian } from '@mujian/js-sdk/react';
import type { ProjectInfo } from '@mujian/js-sdk/types';
import { useEffect, useMemo, useState } from 'react';
import type { PersonaInfo, ProjectInfo } from '@mujian/js-sdk/types';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useGlobalStore } from '@/store/global';
import { mjChatCls } from '@/utils/cls';
import { MessageList } from './MessageList';
import { MsgSend } from './MsgSend';
import { QuickReply } from './QuickReply';
// 扩展Window接口以包含chat对象
declare global {
@@ -14,6 +16,13 @@ declare global {
chat: {
complete?: (message: string) => Promise<void>;
project?: ProjectInfo;
settings: {
persona?: PersonaInfo;
};
/**
* @param index 下标从1开始
*/
setFirstMesIndex?: (index: number) => Promise<void>;
};
};
};
@@ -25,12 +34,15 @@ window.$mj_engine = {
chat: {
complete: undefined,
project: undefined,
settings: {
persona: undefined,
},
},
},
};
export const Chat = () => {
const { init, projectInfo } = useGlobalStore();
const { init, projectInfo, activePersona } = useGlobalStore();
const mujian = useMujian();
const [inputValue, setInputValue] = useState('');
const {
@@ -44,15 +56,18 @@ export const Chat = () => {
deleteMessage,
editMessage,
setSwipe,
loadMoreMessage,
} = useChat({
onError: (e) => {
console.error(e);
},
pageSize: 20,
});
useEffect(() => {
window.$mj_engine.ai.chat.project = projectInfo ?? undefined;
}, [projectInfo]);
window.$mj_engine.ai.chat.settings.persona = activePersona ?? undefined;
}, [projectInfo, activePersona]);
// 自定义删除消息函数
const handleDeleteMessage = async (messageId: string) => {
@@ -92,18 +107,47 @@ export const Chat = () => {
init(mujian);
}, []);
const onSend = append;
const onSend = useCallback(
(query: string) => {
let _q = query;
const marcos = [
{
variable: 'user',
value: activePersona?.name || '',
},
{ variable: 'char', value: projectInfo?.title || '' },
];
for (const macro of marcos) {
_q = _q.replace(new RegExp(`{{${macro.variable}}}`, 'g'), macro.value);
}
return append(_q);
},
[append, projectInfo, activePersona],
);
// 将chat对象挂载到window上
useEffect(() => {
window.$mj_engine.ai.chat.complete = async (message: string) => {
window.$mj_engine.ai.chat.complete = async (message) => {
await onSend(message);
};
return () => {
delete window.$mj_engine.ai.chat.complete;
window.$mj_engine.ai.chat.setFirstMesIndex = async (oneBasedIndex) => {
if (messages.length > 1) {
throw new Error('已有新消息,不允许切换开场白');
}
if (messages.length === 0) {
throw new Error('断言失败:没有开场消息,无法切换开场白');
}
const zeroBasedIndex = Math.floor(oneBasedIndex) - 1;
const swipeLength = messages[0].swipes.length;
if (zeroBasedIndex < 0 || zeroBasedIndex >= swipeLength) {
return;
}
setSwipe(messages[0].id, zeroBasedIndex);
};
}, [onSend]);
}, [onSend, messages, setSwipe]);
const handleInputChange = (value: string) => {
setInputValue(value);
@@ -127,9 +171,13 @@ export const Chat = () => {
: '哎呀,发生了未知错误,刷新页面再试试吧~');
return (
<div className="size-full relative">
<div className={mjChatCls(['container'], 'size-full relative')}>
<ToastProvider placement="top-center" toastProps={{ color: 'danger' }} />
<div
className="h-full opacity-30 blur-xs object-cover absolute top-0 left-1/2 w-[min(640px,100%)] -translate-x-1/2 bg-cover bg-top bg-no-repeat [mask-image:linear-gradient(to_right,transparent,black_100px,black_calc(100%-100px),transparent)]"
className={mjChatCls(
['container-background'],
'h-full opacity-30 blur-xs object-cover absolute top-0 left-1/2 w-[min(640px,100%)] -translate-x-1/2 bg-cover bg-top bg-no-repeat [mask-image:linear-gradient(to_right,transparent,black_100px,black_calc(100%-100px),transparent)]',
)}
style={{
backgroundImage: projectInfo
? `url(${projectInfo.coverImageUrl})`
@@ -137,12 +185,19 @@ export const Chat = () => {
}}
/>
<div
className="size-full z-10 p-3 flex flex-col"
className={mjChatCls(
['container-content'],
'size-full z-10 p-3 flex flex-col',
)}
style={{
paddingTop,
}}
>
<ScrollShadow hideScrollBar className="h-full" size={20}>
<ScrollShadow
hideScrollBar
className={mjChatCls(['container-content-scroll-shadow'], 'h-full pb-2')}
size={20}
>
<MessageList
data={messages}
sendMessage={async (q) => {
@@ -153,13 +208,17 @@ export const Chat = () => {
onEdit={editMessage}
onSwipe={setSwipe}
onRegenerate={regenerate}
onNeedMore={loadMoreMessage}
/>
{errorMessage && (
<Alert
hideIcon
className="mt-2 mb-6"
className={mjChatCls(['error-message'], 'mt-2 mb-6')}
classNames={{
mainWrapper: 'min-h-4 ml-0',
mainWrapper: mjChatCls(
['error-message-mainWrapper'],
'min-h-4 ml-0',
),
}}
color="danger"
title={errorMessage}
@@ -167,7 +226,16 @@ export const Chat = () => {
/>
)}
</ScrollShadow>
<div className="flex flex-col gap-2 mt-2">
<div
className={mjChatCls(['input-container'], 'flex flex-col gap-4 mt-2')}
>
{projectInfo?.config?.qrConfig?.qrList &&
projectInfo?.config?.qrConfig?.qrList.length > 0 && (
<QuickReply
quickReplies={projectInfo?.config?.qrConfig.qrList}
onSend={onSend}
/>
)}
<MsgSend
running={status === 'streaming'}
onSend={onSend}

View File

@@ -28,6 +28,6 @@ body,
height: 100vh;
}
.mes_text> :last-child {
.mes_text > :last-child {
margin-bottom: 0;
}

9
src/utils/cls.ts Normal file
View File

@@ -0,0 +1,9 @@
const PREFIX = 'mj-chat';
export const mjChatCls = (classNames: string[], defaultClassNames?: string) => {
return (
classNames.map((cls) => `${PREFIX}-${cls}`).join(' ') +
' ' +
(defaultClassNames || '')
);
};

View File

@@ -10,5 +10,6 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src'),
},
// dedupe: ['react', 'react-dom'],
},
});