0.4
This commit is contained in:
@@ -25,6 +25,11 @@
|
||||
"quoteStyle": "single"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.{ts,tsx,js,jsx}"],
|
||||
@@ -319,4 +324,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
854
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 发送'}
|
||||
|
||||
115
src/pages/chat/QuickReply.tsx
Normal file
115
src/pages/chat/QuickReply.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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
9
src/utils/cls.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
const PREFIX = 'mj-chat';
|
||||
|
||||
export const mjChatCls = (classNames: string[], defaultClassNames?: string) => {
|
||||
return (
|
||||
classNames.map((cls) => `${PREFIX}-${cls}`).join(' ') +
|
||||
' ' +
|
||||
(defaultClassNames || '')
|
||||
);
|
||||
};
|
||||
@@ -10,5 +10,6 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
// dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user