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" "quoteStyle": "single"
} }
}, },
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [ "overrides": [
{ {
"includes": ["**/*.{ts,tsx,js,jsx}"], "includes": ["**/*.{ts,tsx,js,jsx}"],
@@ -319,4 +324,3 @@
} }
} }
} }

View File

@@ -18,11 +18,10 @@
"dependencies": { "dependencies": {
"@heroui/react": "2.8.3", "@heroui/react": "2.8.3",
"@heroui/theme": "2.4.21", "@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", "@tailwindcss/vite": "4.1.12",
"ahooks": "3.9.5", "ahooks": "3.9.5",
"axios": "1.11.0", "axios": "1.11.0",
"biome": "^0.3.3",
"dayjs": "1.11.18", "dayjs": "1.11.18",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
@@ -37,6 +36,7 @@
"zustand": "5.0.8" "zustand": "5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.10",
"@mujian/cli": "0.0.0", "@mujian/cli": "0.0.0",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/node": "22.13.9", "@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 { Button, ButtonGroup, cn } from '@heroui/react';
import { mjChatCls } from '@/utils/cls';
export type EditActionsProps = { export type EditActionsProps = {
isUser: boolean; isUser: boolean;
@@ -17,26 +18,38 @@ export const EditActions = ({
return ( return (
<div <div
className={cn( className={mjChatCls(
`flex w-full row-start-2 col-start-1 col-end-4`, ['msg-edit-actions-wrapper'],
isUser ? 'justify-end' : 'justify-start', cn(
`flex w-full row-start-2 col-start-1 col-end-4`,
isUser ? 'justify-end' : 'justify-start',
),
)} )}
> >
<ButtonGroup <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" radius="full"
size="sm" size="sm"
variant="light" variant="light"
> >
<Button <Button
className="text-default" className={mjChatCls(
['msg-edit-actions-button', 'msg-edit-actions-button-save'],
'text-default',
)}
size="sm" size="sm"
onPress={onSaveEdit} onPress={onSaveEdit}
> >
</Button> </Button>
<Button <Button
className="text-default" className={mjChatCls(
['msg-edit-actions-button', 'msg-edit-actions-button-cancel'],
'text-default',
)}
size="sm" size="sm"
onPress={onCancelEdit} onPress={onCancelEdit}
> >

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { cn } from '@heroui/react'; import { cn } from '@heroui/react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { mjChatCls } from '@/utils/cls';
export type MessageEditAreaProps = { export type MessageEditAreaProps = {
isUser: boolean; isUser: boolean;
@@ -39,16 +40,22 @@ export const MessageEditArea = ({
return ( return (
<div <div
className="w-full" className={mjChatCls(['msg-edit-textarea-wrapper'], 'w-full')}
style={{ style={{
height: wrapHeight, height: wrapHeight,
}} }}
> >
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className={cn( className={mjChatCls(
'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', '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} value={editedMessage}
onChange={(e) => { onChange={(e) => {

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Button, Textarea } from '@heroui/react'; import { Button, Textarea } from '@heroui/react';
import { CircleStopIcon, SendIcon } from 'lucide-react'; import { CircleStopIcon, SendIcon } from 'lucide-react';
import { mjChatCls } from '@/utils/cls';
interface Props { interface Props {
onSend: (query: string) => void; onSend: (query: string) => void;
@@ -39,46 +40,74 @@ export const MsgSend = ({
return ( return (
<> <>
<Textarea <Textarea
className="dark" className={mjChatCls(['input-textarea'], 'dark')}
classNames={{ classNames={{
label: 'text-black/50 dark:text-white/90', label: mjChatCls(
input: [ ['input-textarea-label'],
'bg-transparent', 'text-black/50 dark:text-white/90',
'text-black/90 dark:text-white/90', ),
'placeholder:text-default-700/50 dark:placeholder:text-white/60', input: mjChatCls(
], ['input-textarea-input'],
innerWrapper: 'bg-transparent items-center', [
inputWrapper: ['shadow-xl', 'bg-dark/40', 'dark:bg-black/40'], 'bg-transparent',
mainWrapper: 'px-2', 'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60',
].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} disabled={running}
endContent={ endContent={
running ? ( running ? (
<Button <Button
className={mjChatCls(
['input-textarea-stop-button'],
'bg-primary',
)}
isIconOnly isIconOnly
color="primary"
radius="full" radius="full"
size="sm" size="sm"
onPress={() => { onPress={() => {
onStop(); onStop();
}} }}
> >
<CircleStopIcon className="w-5 h-5" /> <CircleStopIcon
className={mjChatCls(
['input-textarea-stop-button-icon'],
'w-5 h-5',
)}
/>
</Button> </Button>
) : ( ) : (
<Button <Button
className={mjChatCls(
['input-textarea-send-button'],
'bg-primary',
)}
isIconOnly isIconOnly
color="primary"
radius="full" radius="full"
size="sm" size="sm"
onPress={handleSend} onPress={handleSend}
isDisabled={isEmptyInput} isDisabled={isEmptyInput}
> >
<SendIcon className="w-5 h-5" /> <SendIcon
className={mjChatCls(
['input-textarea-send-button-icon'],
'w-5 h-5',
)}
/>
</Button> </Button>
) )
} }
maxLength={1000} maxLength={50000}
maxRows={4} maxRows={4}
minRows={1} minRows={1}
placeholder={running ? '正在生成...' : '按 Enter 发送'} 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 { useChat, useMujian } from '@mujian/js-sdk/react';
import type { ProjectInfo } from '@mujian/js-sdk/types'; import type { PersonaInfo, ProjectInfo } from '@mujian/js-sdk/types';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useGlobalStore } from '@/store/global'; import { useGlobalStore } from '@/store/global';
import { mjChatCls } from '@/utils/cls';
import { MessageList } from './MessageList'; import { MessageList } from './MessageList';
import { MsgSend } from './MsgSend'; import { MsgSend } from './MsgSend';
import { QuickReply } from './QuickReply';
// 扩展Window接口以包含chat对象 // 扩展Window接口以包含chat对象
declare global { declare global {
@@ -14,6 +16,13 @@ declare global {
chat: { chat: {
complete?: (message: string) => Promise<void>; complete?: (message: string) => Promise<void>;
project?: ProjectInfo; project?: ProjectInfo;
settings: {
persona?: PersonaInfo;
};
/**
* @param index 下标从1开始
*/
setFirstMesIndex?: (index: number) => Promise<void>;
}; };
}; };
}; };
@@ -25,12 +34,15 @@ window.$mj_engine = {
chat: { chat: {
complete: undefined, complete: undefined,
project: undefined, project: undefined,
settings: {
persona: undefined,
},
}, },
}, },
}; };
export const Chat = () => { export const Chat = () => {
const { init, projectInfo } = useGlobalStore(); const { init, projectInfo, activePersona } = useGlobalStore();
const mujian = useMujian(); const mujian = useMujian();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const { const {
@@ -44,15 +56,18 @@ export const Chat = () => {
deleteMessage, deleteMessage,
editMessage, editMessage,
setSwipe, setSwipe,
loadMoreMessage,
} = useChat({ } = useChat({
onError: (e) => { onError: (e) => {
console.error(e); console.error(e);
}, },
pageSize: 20,
}); });
useEffect(() => { useEffect(() => {
window.$mj_engine.ai.chat.project = projectInfo ?? undefined; 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) => { const handleDeleteMessage = async (messageId: string) => {
@@ -92,18 +107,47 @@ export const Chat = () => {
init(mujian); 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上 // 将chat对象挂载到window上
useEffect(() => { useEffect(() => {
window.$mj_engine.ai.chat.complete = async (message: string) => { window.$mj_engine.ai.chat.complete = async (message) => {
await onSend(message); await onSend(message);
}; };
window.$mj_engine.ai.chat.setFirstMesIndex = async (oneBasedIndex) => {
return () => { if (messages.length > 1) {
delete window.$mj_engine.ai.chat.complete; 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) => { const handleInputChange = (value: string) => {
setInputValue(value); setInputValue(value);
@@ -127,9 +171,13 @@ export const Chat = () => {
: '哎呀,发生了未知错误,刷新页面再试试吧~'); : '哎呀,发生了未知错误,刷新页面再试试吧~');
return ( return (
<div className="size-full relative"> <div className={mjChatCls(['container'], 'size-full relative')}>
<ToastProvider placement="top-center" toastProps={{ color: 'danger' }} />
<div <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={{ style={{
backgroundImage: projectInfo backgroundImage: projectInfo
? `url(${projectInfo.coverImageUrl})` ? `url(${projectInfo.coverImageUrl})`
@@ -137,12 +185,19 @@ export const Chat = () => {
}} }}
/> />
<div <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={{ style={{
paddingTop, paddingTop,
}} }}
> >
<ScrollShadow hideScrollBar className="h-full" size={20}> <ScrollShadow
hideScrollBar
className={mjChatCls(['container-content-scroll-shadow'], 'h-full pb-2')}
size={20}
>
<MessageList <MessageList
data={messages} data={messages}
sendMessage={async (q) => { sendMessage={async (q) => {
@@ -153,13 +208,17 @@ export const Chat = () => {
onEdit={editMessage} onEdit={editMessage}
onSwipe={setSwipe} onSwipe={setSwipe}
onRegenerate={regenerate} onRegenerate={regenerate}
onNeedMore={loadMoreMessage}
/> />
{errorMessage && ( {errorMessage && (
<Alert <Alert
hideIcon hideIcon
className="mt-2 mb-6" className={mjChatCls(['error-message'], 'mt-2 mb-6')}
classNames={{ classNames={{
mainWrapper: 'min-h-4 ml-0', mainWrapper: mjChatCls(
['error-message-mainWrapper'],
'min-h-4 ml-0',
),
}} }}
color="danger" color="danger"
title={errorMessage} title={errorMessage}
@@ -167,7 +226,16 @@ export const Chat = () => {
/> />
)} )}
</ScrollShadow> </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 <MsgSend
running={status === 'streaming'} running={status === 'streaming'}
onSend={onSend} onSend={onSend}

View File

@@ -28,6 +28,6 @@ body,
height: 100vh; height: 100vh;
} }
.mes_text> :last-child { .mes_text > :last-child {
margin-bottom: 0; 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: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
// dedupe: ['react', 'react-dom'],
}, },
}); });