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": { "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.65", "@mujian/js-sdk": "0.0.6-beta.mjv.68",
"@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",

10
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ importers:
specifier: 2.4.21 specifier: 2.4.21
version: 2.4.21(tailwindcss@4.1.12) version: 2.4.21(tailwindcss@4.1.12)
'@mujian/js-sdk': '@mujian/js-sdk':
specifier: 0.0.6-beta.65 specifier: 0.0.6-beta.mjv.68
version: 0.0.6-beta.65(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 0.0.6-beta.mjv.68(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: 4.1.12 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)) 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': '@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@mujian/js-sdk@0.0.6-beta.65': '@mujian/js-sdk@0.0.6-beta.mjv.68':
resolution: {integrity: sha512-QMAYn6wx4uLlGSBjkAH8O9gR4x0vgHUpIKupIJSlgIKfjIqgYOZEvvx3/4anH2l6ESXGTR521x2ri9uQj5cuyw==} resolution: {integrity: sha512-bsuev++lvRmS+fD36XljHIw6DnUTFzVrqV69IXKMTb7Vr8W70qQ1rMrn9gN8Lva996JZrXKQ39f3jFrRo1o53g==}
peerDependencies: peerDependencies:
react: ~19.1.1 react: ~19.1.1
react-dom: ~19.1.1 react-dom: ~19.1.1
@@ -3942,7 +3942,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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: dependencies:
'@adobe/css-tools': 4.4.4 '@adobe/css-tools': 4.4.4
ahooks: 3.9.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1) 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; isEditing: boolean;
swipes: string[]; swipes: string[];
activeSwipeId: number; activeSwipeId: number;
panicked?: boolean;
onRegenerate: (() => Promise<void>) | undefined; onRegenerate: (() => Promise<void>) | undefined;
onContinue: (() => Promise<void>) | undefined; onContinue: (() => Promise<void>) | undefined;
onEditButtonClick: () => void; onEditButtonClick: () => void;
@@ -116,6 +117,7 @@ export const MessageActions = ({
item.className || '', item.className || '',
)} )}
onPress={item.onPress} onPress={item.onPress}
isDisabled={item.disabled}
classNames={{ classNames={{
wrapper: mjChatCls( wrapper: mjChatCls(
[ [

View File

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

View File

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

View File

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