This commit is contained in:
Cledwyn Lew
2026-02-07 21:35:33 +08:00
parent 9179e021e1
commit 6f90010c9e
10 changed files with 231 additions and 114 deletions

2
.npmrc
View File

@@ -1 +1 @@
registry=https://registry.npmmirror.com
# registry=https://registry.npmmirror.com

View File

@@ -18,7 +18,7 @@
"dependencies": {
"@heroui/react": "2.8.3",
"@heroui/theme": "2.4.21",
"@mujian/js-sdk": "0.0.6-beta.65",
"@mujian/js-sdk": "0.0.6-beta.mjv.68",
"@tailwindcss/vite": "4.1.12",
"ahooks": "3.9.5",
"axios": "1.11.0",

10
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ importers:
specifier: 2.4.21
version: 2.4.21(tailwindcss@4.1.12)
'@mujian/js-sdk':
specifier: 0.0.6-beta.65
version: 0.0.6-beta.65(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
specifier: 0.0.6-beta.mjv.68
version: 0.0.6-beta.mjv.68(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@tailwindcss/vite':
specifier: 4.1.12
version: 4.1.12(vite@7.1.2(@types/node@22.13.9)(jiti@2.5.1)(lightningcss@1.30.1))
@@ -996,8 +996,8 @@ packages:
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@mujian/js-sdk@0.0.6-beta.65':
resolution: {integrity: sha512-QMAYn6wx4uLlGSBjkAH8O9gR4x0vgHUpIKupIJSlgIKfjIqgYOZEvvx3/4anH2l6ESXGTR521x2ri9uQj5cuyw==}
'@mujian/js-sdk@0.0.6-beta.mjv.68':
resolution: {integrity: sha512-bsuev++lvRmS+fD36XljHIw6DnUTFzVrqV69IXKMTb7Vr8W70qQ1rMrn9gN8Lva996JZrXKQ39f3jFrRo1o53g==}
peerDependencies:
react: ~19.1.1
react-dom: ~19.1.1
@@ -3942,7 +3942,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mujian/js-sdk@0.0.6-beta.65(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@mujian/js-sdk@0.0.6-beta.mjv.68(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@adobe/css-tools': 4.4.4
ahooks: 3.9.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1)

View File

@@ -22,6 +22,7 @@ export type LastMessageActionsProps = {
isEditing: boolean;
swipes: string[];
activeSwipeId: number;
panicked?: boolean;
onRegenerate: (() => Promise<void>) | undefined;
onContinue: (() => Promise<void>) | undefined;
onEditButtonClick: () => void;
@@ -116,6 +117,7 @@ export const MessageActions = ({
item.className || '',
)}
onPress={item.onPress}
isDisabled={item.disabled}
classNames={{
wrapper: mjChatCls(
[

View File

@@ -26,7 +26,7 @@ export type MessageBubbleProps = {
};
export const MessageBubble = ({
// message,
message,
isUser,
isEditing,
editedMessage,
@@ -79,7 +79,13 @@ export const MessageBubble = ({
)}
role="presentation"
>
<MdRenderer content={currentMessage} />
<MdRenderer
content={currentMessage}
extra={{
messageId: message.id,
swipeId: message.activeSwipeId,
}}
/>
</div>
</DropdownTrigger>
<DropdownMenu
@@ -103,6 +109,7 @@ export const MessageBubble = ({
item.className || '',
)}
onPress={item.onPress}
isDisabled={item.disabled}
>
{item.label}
</DropdownItem>
@@ -111,7 +118,13 @@ export const MessageBubble = ({
</Dropdown>
) : (
<div className={mjChatCls(['msg-content'], 'py-4')}>
<MdRenderer content={currentMessage} />
<MdRenderer
content={currentMessage}
extra={{
messageId: message.id,
swipeId: message.activeSwipeId,
}}
/>
</div>
)
) : (

View File

@@ -1,3 +1,6 @@
import { useContext } from 'react';
import { PanicContext } from '../../PanicContext';
export type DropdownItem = {
key: string;
label: string;
@@ -10,6 +13,7 @@ export type DropdownItem = {
| 'warning'
| 'danger';
className?: string;
disabled?: boolean;
};
export type UseDropdownItemsProps = {
@@ -29,11 +33,14 @@ export const useMessageActions = ({
onContinue,
onDelete,
}: UseDropdownItemsProps): DropdownItem[] => {
const { panicked } = useContext(PanicContext);
const dropdownItems: DropdownItem[] = [
{
key: 'edit',
label: '编辑',
onPress: onEditButtonClick,
disabled: panicked,
},
{
key: 'copy',
@@ -46,11 +53,13 @@ export const useMessageActions = ({
key: 'regenerate',
label: '重说',
onPress: async () => await onRegenerate?.(),
disabled: panicked,
},
{
key: 'continue',
label: '继续',
onPress: async () => await onContinue?.(),
disabled: panicked,
},
]
: []),
@@ -60,6 +69,7 @@ export const useMessageActions = ({
onPress: onDelete,
color: 'danger',
className: 'text-danger',
disabled: panicked,
},
];

View File

@@ -83,6 +83,8 @@ export const MessageItem = React.memo((props: MessageItemProps) => {
swipes = [],
activeSwipeId,
mjv,
// sendAt,
} = message;
const isUser = role === 'user';
@@ -183,7 +185,7 @@ export const MessageItem = React.memo((props: MessageItemProps) => {
// 按ctrl点击时打印ID
if (event.ctrlKey) {
event.preventDefault();
console.log('Official Chat Message ID:', id);
console.log('Official Chat Message ID:', id, mjv);
}
}}
>

View File

@@ -1,6 +1,8 @@
import { Button, Textarea } from '@heroui/react';
import { CircleStopIcon, SendIcon } from 'lucide-react';
import { mjChatCls } from '@/utils/cls';
import { useContext } from 'react';
import { PanicContext } from './PanicContext';
interface Props {
onSend: (query: string) => void;
@@ -28,6 +30,8 @@ export const MsgSend = ({
value,
onChange,
}: Props) => {
const { panicked } = useContext(PanicContext);
const isEmptyInput = value.trim().length === 0;
const handleSend = () => {
@@ -64,7 +68,7 @@ export const MsgSend = ({
),
mainWrapper: mjChatCls(['input-textarea-mainWrapper'], 'px-2'),
}}
disabled={running}
disabled={running || panicked}
endContent={
running ? (
<Button

View File

@@ -0,0 +1,3 @@
import { createContext } from 'react';
export const PanicContext = createContext({ panicked: false });

View File

@@ -1,46 +1,108 @@
import { Alert, ScrollShadow, Spinner, ToastProvider } from '@heroui/react';
import { useChat, useMujian } from '@mujian/js-sdk/react';
import type { PersonaInfo, ProjectInfo } from '@mujian/js-sdk/types';
import * as _ from 'lodash-es';
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 { PanicContext } from './PanicContext';
import { QuickReply } from './QuickReply';
let $mj_ai_chat_complete: ((message: string) => Promise<void>) | undefined;
let $mj_ai_chat_project: ProjectInfo | undefined;
let $mj_ai_chat_settings_persona: PersonaInfo | undefined;
let $mj_ai_chat_setFirstMesIndex:
| ((index: number) => Promise<void>)
| undefined;
let $mj_ai_chat_mjv_get:
| ((messageId: string, swipeId: number, path: string) => unknown)
| undefined;
let $mj_ai_chat_mjv_getAll:
| ((messageId: string, swipeId: number) => unknown)
| undefined;
// 扩展Window接口以包含chat对象
declare global {
interface MjEngineAiChat {
complete?: (message: string) => Promise<void>;
project?: ProjectInfo;
settings: {
persona?: PersonaInfo;
};
/**
* @param index 下标从1开始
*/
setFirstMesIndex?: (index: number) => Promise<void>;
}
interface MjEngineAi {
chat: MjEngineAiChat;
}
interface MjEngine {
ai: MjEngineAi;
}
interface Window {
$mj_engine: {
ai: {
chat: {
complete?: (message: string) => Promise<void>;
project?: ProjectInfo;
settings: {
persona?: PersonaInfo;
$mj_engine: MjEngine & {
bind: (extra: { messageId: string; swipeId: number }) => MjEngine & {
ai: {
chat: {
mjv: {
get: (path: string) => unknown;
getAll: () => unknown;
};
};
/**
* @param index 下标从1开始
*/
setFirstMesIndex?: (index: number) => Promise<void>;
};
};
};
}
}
window.$mj_engine = {
const $mj_engine: MjEngine = {
ai: {
chat: {
complete: undefined,
project: undefined,
get complete() {
return $mj_ai_chat_complete;
},
get project() {
return $mj_ai_chat_project;
},
settings: {
persona: undefined,
get persona() {
return $mj_ai_chat_settings_persona;
},
},
get setFirstMesIndex() {
return $mj_ai_chat_setFirstMesIndex;
},
},
},
};
window.$mj_engine = {
...$mj_engine,
bind({ messageId, swipeId }) {
return {
ai: {
chat: {
...$mj_engine.ai.chat,
mjv: {
get(path) {
return $mj_ai_chat_mjv_get?.(messageId, swipeId, path);
},
getAll() {
return $mj_ai_chat_mjv_getAll?.(messageId, swipeId) ?? {};
},
},
},
},
};
},
};
export const Chat = () => {
const { init, projectInfo, activePersona } = useGlobalStore();
const mujian = useMujian();
@@ -65,8 +127,8 @@ export const Chat = () => {
});
useEffect(() => {
window.$mj_engine.ai.chat.project = projectInfo ?? undefined;
window.$mj_engine.ai.chat.settings.persona = activePersona ?? undefined;
$mj_ai_chat_project = projectInfo ?? undefined;
$mj_ai_chat_settings_persona = activePersona ?? undefined;
}, [projectInfo, activePersona]);
// 自定义删除消息函数
@@ -130,10 +192,10 @@ export const Chat = () => {
// 将chat对象挂载到window上
useEffect(() => {
window.$mj_engine.ai.chat.complete = async (message) => {
$mj_ai_chat_complete = async (message) => {
await onSend(message);
};
window.$mj_engine.ai.chat.setFirstMesIndex = async (oneBasedIndex) => {
$mj_ai_chat_setFirstMesIndex = async (oneBasedIndex) => {
if (messages.length > 1) {
throw new Error('已有新消息,不允许切换开场白');
}
@@ -147,6 +209,15 @@ export const Chat = () => {
}
setSwipe(messages[0].id, zeroBasedIndex);
};
$mj_ai_chat_mjv_getAll = (messageId, swipeId) =>
messages.find((m) => m.id === messageId)?.swipeInfo[swipeId]?.mjv?.value;
$mj_ai_chat_mjv_get = (messageId, swipeId, path) => {
const mjv = $mj_ai_chat_mjv_getAll?.(messageId, swipeId);
if (!mjv) {
return;
}
return _.get(mjv, path);
};
}, [onSend, messages, setSwipe]);
const handleInputChange = (value: string) => {
@@ -175,61 +246,67 @@ export const Chat = () => {
margin-top: ${paddingTop}px;
}`;
const saveFailed =
status !== 'streaming' &&
messages.some((m) => m.id.startsWith('not_saved'));
return (
<div className={mjChatCls(['container'], 'size-full relative')}>
<style> {toastStyle} </style>
<ToastProvider
placement="top-center"
toastProps={{ color: 'danger' }}
disableAnimation={true}
regionProps={{
classNames: {
base: 'toast-region',
},
}}
/>
<div
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})`
: undefined,
}}
/>
<div
className={mjChatCls(
['container-content'],
'size-full z-10 p-3 flex flex-col',
)}
style={{
paddingTop,
}}
>
<ScrollShadow
hideScrollBar
<PanicContext.Provider
value={{ panicked: saveFailed || Boolean(errorMessage) }}
>
<div className={mjChatCls(['container'], 'size-full relative')}>
<style> {toastStyle} </style>
<ToastProvider
placement="top-center"
toastProps={{ color: 'danger' }}
disableAnimation={true}
regionProps={{
classNames: {
base: 'toast-region',
},
}}
/>
<div
className={mjChatCls(
['container-content-scroll-shadow'],
'h-full pb-2',
['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)]',
)}
size={20}
style={{
backgroundImage: projectInfo
? `url(${projectInfo.coverImageUrl})`
: undefined,
}}
/>
<div
className={mjChatCls(
['container-content'],
'size-full z-10 p-3 flex flex-col',
)}
style={{
paddingTop,
}}
>
<MessageList
data={messages}
sendMessage={async (q) => {
await onSend(q);
}}
onContinue={continueGenerate}
onDelete={handleDeleteMessage}
onEdit={editMessage}
onSwipe={setSwipe}
onRegenerate={regenerate}
onNeedMore={loadMoreMessage}
/>
{status !== 'streaming' &&
messages.some((m) => m.id.startsWith('not_saved')) && (
<ScrollShadow
hideScrollBar
className={mjChatCls(
['container-content-scroll-shadow'],
'h-full pb-2',
)}
size={20}
>
<MessageList
data={messages}
sendMessage={async (q) => {
await onSend(q);
}}
onContinue={continueGenerate}
onDelete={handleDeleteMessage}
onEdit={editMessage}
onSwipe={setSwipe}
onRegenerate={regenerate}
onNeedMore={loadMoreMessage}
/>
{saveFailed && (
<Alert
hideIcon
title="最近一次保存消息失败,请刷新页面后重试"
@@ -241,41 +318,47 @@ export const Chat = () => {
variant="bordered"
/>
)}
{errorMessage && (
<Alert
hideIcon
className={mjChatCls(['error-message'], 'mt-2 mb-6')}
classNames={{
mainWrapper: mjChatCls(
['error-message-mainWrapper'],
'min-h-4 ml-0',
),
}}
color="danger"
title={errorMessage}
variant="bordered"
/>
)}
</ScrollShadow>
<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}
{errorMessage && (
<Alert
hideIcon
className={mjChatCls(['error-message'], 'mt-2 mb-6')}
classNames={{
mainWrapper: mjChatCls(
['error-message-mainWrapper'],
'min-h-4 ml-0',
),
description: 'opacity-80 text-xs',
}}
color="danger"
title="出错啦,请刷新页面后重试"
description={errorMessage}
variant="bordered"
/>
)}
<MsgSend
running={status === 'streaming'}
onSend={onSend}
onStop={stop}
value={inputValue}
onChange={handleInputChange}
/>
</ScrollShadow>
<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}
onStop={stop}
value={inputValue}
onChange={handleInputChange}
/>
</div>
</div>
</div>
</div>
</PanicContext.Provider>
);
};