add ai-chat

This commit is contained in:
2025-05-25 00:05:04 +08:00
parent 500b622d9e
commit e38b8df9f0
60 changed files with 5333 additions and 326 deletions

View File

@@ -0,0 +1,124 @@
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { Button } from '@/components/a/button.tsx';
import { TextEditor } from '@kevisual/markdown-editor/tiptap/editor.ts';
import { Select } from '@/components/a/select.tsx';
import { useEffect, useRef, useState } from 'react';
import { getSuggestionItems } from '../editor/suggestion/item';
import { html2md } from '@kevisual/markdown-editor/tiptap/index.ts';
import { chatId } from '../utils/uuid';
export const ChatContextDialog = () => {
const computedHeight = getComputedHeight();
const { showContext, setShowContext, updateMessage, contextMessage, setContextMessage, setModelId, modelId } = useChatStore(
useShallow((state) => ({
showContext: state.showContext,
setShowContext: state.setShowContext,
updateMessage: state.updateMessage,
contextMessage: state.contextMessage,
setContextMessage: state.setContextMessage,
setModelId: state.setModelId,
modelId: state.modelId,
})),
);
const ref = useRef<HTMLDivElement>(null);
const editorRef = useRef<TextEditor | null>(null);
useEffect(() => {
if (ref.current) {
editorRef.current = new TextEditor();
editorRef.current.createEditor(ref.current, {
items: getSuggestionItems(),
});
}
return () => {
if (ref.current && editorRef.current) {
editorRef.current?.destroy?.();
}
};
}, []);
useEffect(() => {
if (showContext && contextMessage?.content) {
contentToEditor(contextMessage.content);
contextMessage?.role && setRole(contextMessage.role);
} else if (showContext && typeof contextMessage === 'undefined') {
editorRef.current?.setContent('');
}
}, [showContext]);
const contentToEditor = async (content: string) => {
editorRef.current?.setContent(content);
};
const onSave = async () => {
const hasId = contextMessage?.id;
const html = editorRef.current?.getHtml()!;
const md = await html2md(html);
const newMessage = {
...contextMessage,
role: role,
id: hasId || chatId(),
content: md,
name: 'user',
};
if (!hasId) {
newMessage.createdAt = new Date().getTime();
}
newMessage.updatedAt = new Date().getTime();
updateMessage(newMessage);
setShowContext(false);
};
const [role, setRole] = useState<string>('user');
return (
<DragModal
focus={modelId === 'chat-context'}
title={
<DragModalTitle
title='Markdown编辑器'
onClick={() => {
setModelId('chat-context');
}}
onClose={() => {
setShowContext(false);
}}
/>
}
containerClassName={showContext ? '' : 'hidden'}
content={
<div className='w-full h-full p-2 '>
<div className='text-sm text-gray-500 border-b border-gray-200 pb-2 flex items-center gap-2'>
:
<Select
className='w-40 ml-2'
size='small'
value={role}
onChange={(e) => {
setRole(e);
}}
options={[
{ label: 'User', value: 'user' },
{ label: 'Assistant', value: 'assistant' },
{ label: 'System', value: 'system' },
{ label: 'Tools', value: 'tool' },
]}
/>
</div>
<div className='w-full scrollbar' style={{ height: 'calc(100% - 90px)' }} ref={ref}></div>
<div className='flex justify-end gap-2 ' style={{ height: '40px' }}>
<Button color='primary' onClick={onSave}>
</Button>
<Button onClick={() => setShowContext(false)}></Button>
</div>
</div>
}
defaultSize={{
width: 600,
height: 400,
}}
style={{
left: computedHeight.width / 2 - 300,
top: computedHeight.height / 2 - 200,
}}
/>
);
};

View File

@@ -0,0 +1,46 @@
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { ChatCopyList } from './List';
export const ChatCopyDialog = () => {
const store = useChatStore(
useShallow((state) => ({
showCopy: state.showCopy,
setShowCopy: state.setShowCopy,
setModelId: state.setModelId,
modelId: state.modelId,
})),
);
const computedHeight = getComputedHeight();
if (!store.showCopy) {
return null;
}
return (
<DragModal
focus={store.modelId === 'chat-copy'}
title={
<DragModalTitle
title='Chat Copy'
onClose={() => store.setShowCopy(false)}
onClick={() => {
store.setModelId('chat-copy');
}}
/>
}
content={
<div className='w-full h-full p-2 overflow-y-auto scrollbar'>
<ChatCopyList />
</div>
}
defaultSize={{
width: 600,
height: 400,
}}
style={{
left: computedHeight.width / 2 - 300,
top: computedHeight.height / 2 - 200,
}}
/>
);
};

View File

@@ -0,0 +1,151 @@
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { IconButton } from '@/components/a/button.tsx'
import { Bug, BugOff, Copy, Download, RotateCcw, Upload, X } from 'lucide-react';
import { Divider } from '@/components/a/divider.tsx'
import { Tooltip } from '@/components/a/tooltip.tsx'
import { copyText } from '../utils/copy';
import { useEffect, useRef, useState } from 'react';
import { getCodeTemplate, runCode } from '../store/generte-text';
import clsx from 'clsx';
export type SelfUsage = {
group: string;
model: string;
token: number;
[key: string]: any;
};
export type UsageData = {
rootUsage?: {
token?: number;
};
selfUsage?: SelfUsage[];
};
export const ChatCopyList = () => {
const store = useChatStore(
useShallow((state) => ({
currentUserModel: state.currentUserModel,
setCurrentUserModel: state.setCurrentUserModel,
modelList: state.modelList,
clearConfigCache: state.clearConfigCache,
showSetting: state.showSetting,
getModelList: state.getModelList,
messages: state.messages,
})),
);
const contentEditableRef = useRef<HTMLDivElement>(null);
const [text, setText] = useState('');
const [mode, setMode] = useState<'edit' | 'copy'>('edit');
const [code, setCode] = useState('');
useEffect(() => {
generte();
}, [store.messages, code]);
useEffect(() => {
const code = localStorage.getItem('code-copy-template');
const cacheMode = localStorage.getItem('code-copy-mode');
if (code) {
setCode(code);
} else {
setCode(getCodeTemplate());
}
if (cacheMode) {
setMode(cacheMode as 'edit' | 'copy');
}
}, []);
const generte = async () => {
if (!contentEditableRef.current) return;
if (!code) return;
try {
const jsModule = await runCode(code);
const result = await jsModule.generateText(store.messages);
setText(result);
contentEditableRef.current.innerHTML = result;
} catch (error) {
contentEditableRef.current.innerHTML = 'error run code';
}
};
const setCodeTemplate = () => {
const codeTemplate = getCodeTemplate();
localStorage.setItem('code-copy-template', codeTemplate);
setCode(codeTemplate);
};
const changeMode = () => {
setMode(mode === 'edit' ? 'copy' : 'edit');
localStorage.setItem('code-copy-mode', mode === 'edit' ? 'copy' : 'edit');
};
return (
<div className='w-full h-full p-2'>
<div className='pt-1 flex gap-2 pb-4'>
<Tooltip title='复制'>
<IconButton
onClick={() => {
copyText(text);
}}>
<Copy className='w-4 h-4' />
</IconButton>
</Tooltip>
<Tooltip title='导出json'>
<IconButton>
<Download className='w-4 h-4' />
</IconButton>
</Tooltip>
<Tooltip title='导入json'>
<IconButton>
<Upload className='w-4 h-4' />
</IconButton>
</Tooltip>
<Divider orientation='vertical' />
<Tooltip title={mode === 'edit' ? '编辑导出模式' : '预览模式'}>
<IconButton
onClick={() => {
changeMode();
}}>
{mode === 'edit' ? <Bug className='w-4 h-4' /> : <BugOff className='w-4 h-4' />}
</IconButton>
</Tooltip>
<Tooltip title='恢复默认的代码配置'>
<IconButton onClick={() => setCodeTemplate()}>
<RotateCcw className='w-4 h-4' />
</IconButton>
</Tooltip>
</div>
<Divider />
<div
className='flex gap-2 py-4 text-sm scrollbar overflow-y-auto'
style={{
height: 'calc(100% - 40px)',
}}>
<div
className={clsx('w-full h-full whitespace-pre-wrap scrollbar', mode === 'edit' ? 'flex-1' : '')}
contentEditable
style={{
outline: 'none',
overflowY: 'auto',
}}
ref={contentEditableRef}></div>
<div className={clsx('w-full h-full whitespace-pre-wrap', mode === 'edit' ? 'flex-1' : 'hidden')}>
<Tooltip title='自动缓存到code-copy-template中'>
<div className='text-sm w-[140px] flex gap-2 text-gray-500 pb-2 items-center'>
<div className='p-0.5 hover:bg-gray-100 rounded-md cursor-pointer' onClick={() => changeMode()}>
<X className='w-4 h-4' />
</div>
</div>
</Tooltip>
<textarea
className='w-full border scrollbar border-gray-200 rounded-md shadow-sm p-4'
value={code}
onChange={(e) => {
localStorage.setItem('code-copy-template', code);
setCode(e.target.value);
}}
style={{
height: 'calc(100% - 40px)',
outline: 'none',
overflowY: 'auto',
}}></textarea>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,41 @@
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { ChatHistoryList } from './List';
export const ChatHistoryDialog = ({ storeId }: { storeId: string }) => {
const { showList, setShowList, setModelId, modelId } = useChatStore(
useShallow((state) => ({ showList: state.showList, setShowList: state.setShowList, setModelId: state.setModelId, modelId: state.modelId })),
);
const computedHeight = getComputedHeight();
if (!showList) {
return null;
}
return (
<DragModal
focus={modelId === 'chat-history'}
title={
<DragModalTitle
title='Chat History'
onClose={() => setShowList(false)}
onClick={() => {
setModelId('chat-history');
}}
/>
}
content={
<div className='w-full h-full p-2 overflow-y-auto scrollbar'>
<ChatHistoryList storeId={storeId} />
</div>
}
defaultSize={{
width: 600,
height: 400,
}}
style={{
left: computedHeight.width / 2 - 300,
top: computedHeight.height / 2 - 200,
}}
/>
);
};

View File

@@ -0,0 +1,117 @@
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { IconButton, Button } from '@/components/a/button.tsx';
import { Tooltip } from '@/components/a/tooltip';
import { Edit, Plus, Trash, X } from 'lucide-react';
import { Input as TextField } from '@/components/a/input';
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
import clsx from 'clsx';
import { Controller, useForm } from 'react-hook-form';
import { Confirm } from '@/components/a/confirm';
export const ChatHistoryList = ({ storeId }: { storeId: string }) => {
const { historyList, setId, updateChat, deleteChat, setShowList, newChat, setNewChat } = useChatStore(
useShallow((state) => ({
historyList: state.historyList,
setId: state.setId,
updateChat: state.updateChat,
deleteChat: state.deleteChat,
setShowList: state.setShowList,
newChat: state.newChat,
setNewChat: state.setNewChat,
})),
);
const onChoose = (id: string) => {
setId(id);
setShowList(false);
setHistoryState({
[storeId]: {
chatId: id,
},
});
const url = new URL(location.href);
url.searchParams.set('chatId', id);
setHistoryState({}, url.toString());
};
const { control, handleSubmit, reset, getValues } = useForm({ defaultValues: { title: '', id: '' } });
const onFinish = async (data: any) => {
const res = await updateChat(data);
if (res.code === 200) {
setNewChat(false);
reset({});
}
};
const id = getValues('id');
return (
<div className='w-full h-full'>
<div className='flex justify-end px-4 py-2 border-b border-gray-200'>
<Tooltip title='New Context'>
<IconButton onClick={() => setNewChat(!newChat)}>{newChat ? <X className='w-4 h-4' /> : <Plus className='w-4 h-4' />}</IconButton>
</Tooltip>
</div>
<div className='scrollbar pt-4' style={{ height: 'calc(100% - 60px)' }}>
<div className={clsx('flex flex-col gap-2', newChat ? 'hidden' : '')}>
{historyList.map((item) => {
const chatState = getHistoryState()[storeId];
const isCurrent = chatState?.chatId === item.id;
return (
<div key={item.id} className={clsx('flex justify-between items-center px-3 py-2', isCurrent ? 'bg-gray-100' : '')}>
<div
className='min-w-[200px] truncate min-h-[20px] cursor-pointer'
onClick={() => {
onChoose(item.id);
}}>
{item.title}
</div>
<div className='flex gap-2'>
<Tooltip title='编辑标题'>
<IconButton
onClick={(e) => {
e.stopPropagation();
setNewChat(true);
console.log('setNewChat', item.id);
reset({
id: item.id,
title: item.title,
});
}}>
<Edit className='w-4 h-4' />
</IconButton>
</Tooltip>
<Confirm
onOk={(e) => {
e.stopPropagation();
deleteChat(item.id);
}}>
<IconButton>
<Trash className='w-4 h-4' />
</IconButton>
</Confirm>
</div>
</div>
);
})}
{historyList.length === 0 && <div className='text-center text-gray-500 py-2'>No chat history</div>}
</div>
<div className={clsx('flex flex-col gap-2 p-4', newChat ? '' : 'hidden')}>
<form className='flex flex-col gap-2 ' onSubmit={handleSubmit(onFinish)}>
<Controller control={control} name='title' render={({ field }) => <TextField {...field} placeholder='标题' label='标题' />} />
<div className='flex gap-2'>
<Button type='submit' color='primary'>
{id ? '编辑' : '创建'}
</Button>
<Button
type='reset'
onClick={() => {
setNewChat(false);
reset({});
}}>
</Button>
</div>
</form>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { ChatSettingList } from './List';
export const ChatModelSettingDialog = () => {
const { showChatSetting, setShowChatSetting, setModelId, modelId } = useChatStore(
useShallow((state) => ({
showChatSetting: state.showChatSetting,
setShowChatSetting: state.setShowChatSetting,
setModelId: state.setModelId,
modelId: state.modelId,
})),
);
const computedHeight = getComputedHeight();
if (!showChatSetting) {
return null;
}
return (
<DragModal
focus={modelId === 'chat-model-setting'}
title={
<DragModalTitle
title='Chat Models Setting'
onClose={() => setShowChatSetting(false)}
onClick={() => {
setModelId('chat-model-setting');
}}
/>
}
content={
<div className='w-full h-full p-2 overflow-y-auto scrollbar'>
<ChatSettingList />
</div>
}
defaultSize={{
width: 600,
height: 400,
}}
style={{
left: computedHeight.width / 2 - 300,
top: computedHeight.height / 2 - 200,
}}
/>
);
};

View File

@@ -0,0 +1,67 @@
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { IconButton } from '@/components/a/button.tsx'
import { CircleCheckBig, Edit, LassoSelectIcon } from 'lucide-react';
import clsx from 'clsx';
import { Tooltip } from '@/components/a/tooltip.tsx'
export const ChatSettingList = () => {
const { currentUserModel, setCurrentUserModel, modelList, showChatSetting, setShowChatSetting } = useChatStore(
useShallow((state) => ({
currentUserModel: state.currentUserModel,
setCurrentUserModel: state.setCurrentUserModel,
modelList: state.modelList,
showChatSetting: state.showChatSetting,
setShowChatSetting: state.setShowChatSetting,
})),
);
return (
<div className='w-full h-full p-2'>
{modelList?.map?.((model, index) => {
const selectOpts = model.selectOpts || ([] as { group: string; provider: string; model: string }[]);
const isCurrentUserModel = currentUserModel?.username === model.username;
const isLastModel = index === modelList.length - 1;
return (
<div key={index} className={clsx('border-b-gray-200 border-b pb-2', isLastModel ? 'border-b-0' : '')}>
<div className='text-lg font-bold capitalize'>
<Tooltip title={`使用用户: ${model.username} 的对话模型`}>
<div className='cursor-pointer'>user: {model.username}</div>
</Tooltip>
</div>
{selectOpts.map((opt, index) => {
const isLast = index === selectOpts.length - 1;
const isSelected = isCurrentUserModel && currentUserModel?.model?.group === opt.group && currentUserModel?.model?.model === opt.model;
return (
<div
key={'select-opt-' + index}
className={clsx('border-b-gray-200 p-2 flex justify-between', isLast ? 'border-b-0' : 'border-b', isSelected ? 'bg-gray-100' : '')}>
<div>
<div>group: {opt.group}</div>
<div>provider: {opt.provider}</div>
<div>model: {opt.model}</div>
{opt.description && <div>{opt.description}</div>}
</div>
<div className=' gap-2'>
<Tooltip title='设置为当前模型'>
<IconButton
onClick={() => {
setCurrentUserModel({
username: model.username,
model: opt,
});
setShowChatSetting(false);
}}>
<CircleCheckBig className='w-4 h-4' />
</IconButton>
</Tooltip>
</div>
</div>
);
})}
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { ChatSettingList } from './List';
export const ChatSettingDialog = () => {
const { showSetting, setShowSetting, setModelId, modelId } = useChatStore(
useShallow((state) => ({
showSetting: state.showSetting,
setShowSetting: state.setShowSetting,
setModelId: state.setModelId,
modelId: state.modelId,
})),
);
const computedHeight = getComputedHeight();
if (!showSetting) {
return null;
}
return (
<DragModal
focus={modelId === 'chat-setting'}
title={
<DragModalTitle
title='Chat Setting'
onClose={() => setShowSetting(false)}
onClick={() => {
setModelId('chat-setting');
}}
/>
}
content={
<div className='w-full h-full p-2 overflow-y-auto scrollbar'>
<ChatSettingList />
</div>
}
defaultSize={{
width: 600,
height: 400,
}}
style={{
left: computedHeight.width / 2 - 300,
top: computedHeight.height / 2 - 200,
}}
/>
);
};

View File

@@ -0,0 +1,136 @@
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { IconButton } from '@/components/a/button.tsx';
import { BookmarkX, CircleCheckBig, Copy, Edit, LassoSelectIcon, RefreshCcw, Trash } from 'lucide-react';
import clsx from 'clsx';
import { Divider } from '@/components/a/divider.tsx'
import { Tooltip } from '@/components/a/tooltip.tsx'
import { useEffect, useState } from 'react';
import { queryChat } from '../store';
import { toast } from 'react-toastify';
import prettyBytes from 'pretty-bytes';
import { copyText } from '../utils/copy';
export type SelfUsage = {
group: string;
model: string;
token: number;
[key: string]: any;
};
export type UsageData = {
rootUsage?: {
token?: number;
};
selfUsage?: SelfUsage[];
};
export const ChatSettingList = () => {
const store = useChatStore(
useShallow((state) => ({
currentUserModel: state.currentUserModel,
setCurrentUserModel: state.setCurrentUserModel,
modelList: state.modelList,
clearConfigCache: state.clearConfigCache,
showSetting: state.showSetting,
getModelList: state.getModelList,
messages: state.messages,
})),
);
const [usageData, setUsageData] = useState<UsageData>({});
useEffect(() => {
if (store.showSetting) {
getUsage();
}
}, [store.showSetting]);
const getUsage = async () => {
const res = await queryChat.getChatUsage();
console.log(res);
if (res.code === 200) {
setUsageData(res.data);
} else {
toast.error(res.message);
}
};
const clearSelfUsage = async () => {
const res = await queryChat.clearSelfUsage();
if (res.code === 200) {
toast.success(res.message);
} else {
toast.error(res.message);
}
};
return (
<div className='w-full h-full p-2'>
<div className='pt-1 flex gap-2 pb-4'>
<Tooltip title='刷新本地模型配置项, 默认会缓存到浏览器。'>
<IconButton onClick={() => store.getModelList(true)}>
<RefreshCcw className='w-4 h-4' />
</IconButton>
</Tooltip>
<Divider orientation='vertical' />
<Tooltip title='复制所有的消息'>
<IconButton
onClick={() => {
console.log(store.messages);
copyText(JSON.stringify(store.messages));
}}>
<Copy className='w-4 h-4' />
</IconButton>
</Tooltip>
<Divider orientation='vertical' />
<Tooltip title='清除自己的服务器缓存配置项'>
<IconButton
onClick={() => {
store.clearConfigCache();
}}>
<BookmarkX className='w-4 h-4' />
</IconButton>
</Tooltip>
<Tooltip title='清除当前自己模型的统计'>
<IconButton
onClick={() => {
clearSelfUsage();
}}>
<Trash className='w-4 h-4' />
</IconButton>
</Tooltip>
</div>
<Divider />
<div
className='flex flex-col gap-2 py-2 text-sm scrollbar overflow-y-auto'
style={{
height: 'calc(100% - 40px)',
}}>
<div>使用root的模型统计: token: {prettyBytes(usageData.rootUsage?.token || 0)}</div>
<div className='py-2'>
使
<div className='flex flex-col gap-2 pl-4 mt-3'>
{usageData.selfUsage?.map((item) => {
const keys = Object.keys(item);
const defaultKeys = ['model', 'token', 'group'];
const days = keys.filter((key) => !defaultKeys.includes(key));
return (
<div key={item.model} className='flex items-center gap-2'>
<div className='text-sm shadow-sm p-2 rounded-md w-full border border-gray-200'>
<div className='font-bold'>group: {item.group}</div>
<div className=' flex gap-2 items-center'>
<div className=' w-2 h-2 bg-secondary rounded-full' /> {item.model}
</div>
<div>token: {prettyBytes(item.token)}</div>
<div className='text-xs text-gray-500'>
{days.map((day) => {
return (
<div key={day}>
{day}: {item[day] || 0}
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,115 @@
import { throttle } from 'lodash-es';
import { Marked } from 'marked';
import 'highlight.js/styles/github.css'; // 你可以选择其他样式
import { useRef, useEffect } from 'react';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
import clsx from 'clsx';
const marked = new Marked(
markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang, info) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
}),
);
type ResponseTextProps = {
response: Response;
onFinish?: (text: string) => void;
onChange?: (text: string) => void;
className?: string;
id?: string;
};
export const ResponseText = (props: ResponseTextProps) => {
const ref = useRef<HTMLDivElement>(null);
const render = async () => {
const response = props.response;
if (!response) return;
const msg = ref.current!;
if (!msg) {
console.log('msg is null');
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
const reader = response.body?.getReader();
const decoder = new TextDecoder('utf-8');
let done = false;
while (!done) {
const { value, done: streamDone } = await reader!.read();
done = streamDone;
if (value) {
const chunk = decoder.decode(value, { stream: true });
// 更新状态,实时刷新 UI
msg.innerHTML += chunk;
}
}
if (done) {
props.onFinish && props.onFinish(msg.innerHTML);
}
};
useEffect(() => {
render();
}, []);
return <div id={props.id} className={clsx('response markdown-body', props.className)} ref={ref}></div>;
};
export const ResponseMarkdown = (props: ResponseTextProps) => {
const ref = useRef<HTMLDivElement>(null);
let content = '';
const render = async () => {
const response = props.response;
if (!response) return;
const msg = ref.current!;
if (!msg) {
console.log('msg is null');
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
const reader = response.body?.getReader();
const decoder = new TextDecoder('utf-8');
let done = false;
while (!done) {
const { value, done: streamDone } = await reader!.read();
done = streamDone;
if (value) {
const chunk = decoder.decode(value, { stream: true });
content = content + chunk;
renderThrottle(content);
}
}
if (done) {
props.onFinish && props.onFinish(msg.innerHTML);
}
};
const renderThrottle = throttle(async (markdown: string) => {
const msg = ref.current!;
msg.innerHTML = await marked.parse(markdown);
props.onChange?.(msg.innerHTML);
}, 100);
useEffect(() => {
render();
}, [props.response]);
return <div id={props.id} className={clsx('response markdown-body', props.className)} ref={ref}></div>;
};
export const Markdown = (props: { className?: string; markdown: string; id?: string }) => {
const ref = useRef<HTMLDivElement>(null);
const parse = async () => {
const md = await marked.parse(props.markdown);
const msg = ref.current!;
msg.innerHTML = md;
};
useEffect(() => {
parse();
}, []);
return <div id={props.id} ref={ref} className={clsx('markdown-body', props.className)}></div>;
};

View File

@@ -0,0 +1,18 @@
import { TextEditor } from '@kevisual/markdown-editor/tiptap/editor.ts';
import { useEffect, useRef } from 'react';
import { getSuggestionItems } from './suggestion/item';
export const MarkdownEditor = ({ onSave }: { onSave: () => void }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
const editor = new TextEditor();
editor.createEditor(ref.current, {
markdown: '## Hello World',
items: getSuggestionItems(),
});
}
}, []);
return <div className='w-full h-full' ref={ref}></div>;
};

View File

@@ -0,0 +1,25 @@
export interface CommandItem {
title: string;
description: string;
content: string;
}
export const getSuggestionItems = (): CommandItem[] => {
return [
{
title: 'Now',
description: 'Insert current time',
content: new Date().toLocaleTimeString(),
},
{
title: 'Today',
description: "Insert today's date",
content: new Date().toLocaleDateString(),
},
{
title: 'Datetime',
description: 'Insert current date and time',
content: new Date().toLocaleString(),
},
];
};

View File

@@ -0,0 +1,14 @@
@import 'tailwindcss';
@import '@kevisual/markdown-editor/index.css';
.sortable-swap-highlight {
border: 1px solid red;
background-color: aquamarine;
}
.sortable-ghost {
@apply bg-gray-200 border border-gray-400 shadow-sm rounded-lg;
}
.sortable-drag {
@apply border bg-gray-500 border-gray-200 shadow-sm rounded-lg text-white;
}

115
src/apps/ai-chat/index.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { StoreContextProvider, useStore } from '@kevisual/store/react';
import { useShallow } from 'zustand/shallow';
import { createChatStore, useChatStore } from './store';
import { Messages } from './modules/MessageList';
import { useEffect, useLayoutEffect } from 'react';
import { ModelNav } from './modules/ModelNav';
import { Menu } from 'lucide-react';
import { IconButton } from '@/components/a/button.tsx';
import { ChatHistoryDialog } from './chat-history/Dialog';
import { getHistoryState } from '@kevisual/store/web-page.js';
import { ChatContextDialog } from './chat-context/Dialog';
import { ChatModelSettingDialog } from './chat-model-setting/Dialog';
import { ChatSettingDialog } from './chat-setting/Dialog';
import { ChatCopyDialog } from './chat-copy/Dialog';
import { ToastProvider } from '@/modules/toast/Provider';
import clsx from 'clsx';
import { ChatHistoryList } from './chat-history/List';
import './index.css';
type AppProps = {
storeId?: string;
};
export const App = (props: AppProps) => {
const storeId = props.storeId || 'ai-chat-store';
useLayoutEffect(() => {
// const token = localStorage.getItem('token');
// if (!token) {
// toastLogin();
// }
}, []);
return (
<ToastProvider>
<StoreContextProvider id={storeId} stateCreator={createChatStore}>
<Chat storeId={storeId} />
</StoreContextProvider>
</ToastProvider>
);
};
export const Chat = ({ storeId }: { storeId: string }) => {
const { id, init, messages, showList, setShowList, setId, getChatList, chatData } = useChatStore(
useShallow((state) => {
return {
id: state.id,
init: state.init,
messages: state.messages,
showList: state.showList,
setShowList: state.setShowList,
setId: state.setId,
getChatList: state.getChatList,
chatData: state.chatData,
};
}),
);
useEffect(() => {
if (showList) {
getChatList();
}
}, [showList]);
useEffect(() => {
init(id);
}, [id]);
useEffect(() => {
const chat = getHistoryState()[storeId];
const chatId = chat?.chatId;
const url = new URL(window.location.href);
const urlChatId = url.searchParams.get('chatId');
if (chatId) {
setId(chatId);
} else if (urlChatId) {
setId(urlChatId);
} else {
getChatList();
}
}, []);
return (
<div className='w-full h-full relative'>
{!id && (
<div className='w-full h-full flex flex-col justify-center items-center'>
<ChatHistoryList storeId={storeId} />
</div>
)}
<div className={clsx('w-full h-full flex flex-col ', !id && 'hidden')}>
<IconButton
className='absolute top-2 left-2 w-8 h-8'
style={{
position: 'absolute',
}}
onClick={() => setShowList(!showList)}>
<Menu className='w-4 h-4' />
</IconButton>
<div className='h-[56px] flex justify-between items-center px-6 w-full lg:w-[80%] mx-auto'>
<ModelNav />
</div>
<div
className='relative overflow-auto scrollbar border border-gray-200 rounded shadow-sm mb-2 px-6 py-2 w-full lg:w-[80%] mx-auto'
style={{
height: 'calc(100% - 56px)',
}}>
<div className='mt-2 mb-5 min-h-[40px] relative'>
<div className='text-sm text-gray-500 border-b border-gray-200 pb-2 truncate'>{chatData?.title}</div>
<Messages messages={messages} />
</div>
</div>
</div>
<ChatContextDialog />
<ChatHistoryDialog storeId={storeId} />
<ChatModelSettingDialog />
<ChatSettingDialog />
<ChatCopyDialog />
</div>
);
};

View File

@@ -0,0 +1,199 @@
import { ResponseMarkdown, Markdown } from '@/components/ai/RenderMarkdown';
import clsx from 'clsx';
import { Actions } from './actions/Actions';
import { ChastHistoryMessage } from '../store/type';
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { IconButton } from '@/components/a/button';
import { Eraser, Move, Plus } from 'lucide-react';
import { Tooltip } from '@/components/a/tooltip';
import { ReactSortable } from 'react-sortablejs';
import { useEffect, useMemo } from 'react';
export type MessageData = {
content: string;
role: 'user' | 'assistant' | 'system';
response?: Response | null;
className?: string;
onFinish?: (text: string) => void;
// onFinish之前每200ms调用一次
onChange?: (text: string) => void;
id?: string;
randomId?: string;
};
export type MessageProps = { data: ChastHistoryMessage } & MessageData;
export const AssistantMessage = (props: MessageProps) => {
if (props.response) {
return <ResponseMarkdown className={clsx(props.className)} onChange={props.onChange} response={props.response} onFinish={props.onFinish} />;
}
return <Markdown className={clsx(props.className)} markdown={props.content} />;
};
export const Message = (props: MessageProps) => {
const isUser = props.role === 'user';
const isHide = !!props.data.hide;
const isNoUse = !!props.data.noUse;
const { showAllContext, updateMessage } = useChatStore(
useShallow((state) => {
return {
showAllContext: state.showAllContext,
updateMessage: state.updateMessage,
};
}),
);
if (!showAllContext && isHide) {
// return <div className=''></div>;
return null;
}
const isNotShow = !showAllContext && isHide;
const isContext = !isHide && !isNoUse;
const onNoUseChange = () => {
const data = props.data;
const newData = { ...data, noUse: !data.noUse };
updateMessage(newData);
};
// const imgUrl = '/root/center/panda.jpg'
const imgUrl = 'https://kevisual.xiongxiao.me/root/center/panda.jpg';
return (
<div className={clsx('py-2 px-2 mt-1', props.className, isContext && 'shadow-md border-b border-gray-300 box-content rounded-sm', isNotShow && 'hidden')}>
<div className={clsx('chat-message')}>
<div
className={clsx(
{
'message-user': isUser,
'message-assistant': !isUser,
},
'flex flex-col',
)}>
<div>
<div className=' flex gap-4 group items-center'>
<div
className='cursor-pointer'
onClick={() => {
onNoUseChange();
}}>
<div className={clsx('message-avatar flex gap-3 items-center py-2', isUser && 'hidden')}>
<img src={imgUrl} alt='' className='w-6 h-6 rounded-full' />
<div className='' style={{ textTransform: 'capitalize' }}>
{props.role}
</div>
</div>
<div className={clsx('message-avatar flex gap-3 items-center py-2', !isUser && 'hidden')}>
<img src={imgUrl} alt='' className='w-6 h-6 rounded-full' />
<div
className=''
style={{
textTransform: 'capitalize',
}}>
{props.role}
</div>
</div>
</div>
<div className='message-move hidden group-hover:block'>
<div className='flex gap-2 cursor-pointer'>
<Move className='w-4 h-4' />
</div>
</div>
</div>
<div className={clsx('message-content scrollbar overflow-x-hidden', isHide && 'opacity-50')}>
<AssistantMessage {...props} className='p-2 rounded' />
</div>
</div>
</div>
</div>
<Actions data={props.data} />
</div>
);
};
export const Messages = (props: { messages: ChastHistoryMessage[] }) => {
const { messages } = props;
const { setMessages, setContextMessage, setShowContext, showAllContext } = useChatStore(
useShallow((state) => {
return {
showAllContext: state.showAllContext,
setMessages: state.setMessages,
setContextMessage: state.setContextMessage,
setShowContext: state.setShowContext,
};
}),
);
const onEraser = () => {
const newMessages = messages.map((message) => {
return {
...message,
noUse: true,
};
});
setMessages([...newMessages]);
};
const list = messages.filter((message) => {
const isHide = !!message.hide;
return !(!showAllContext && isHide);
});
const reactSortable = useMemo(() => {
return (
<ReactSortable
handle='.message-move'
list={list as any}
setList={(newList) => {}}
onEnd={(event) => {
const { newIndex, oldIndex } = event;
const newMessages = [...messages];
// @ts-ignore
const _oldIndex = messages.findIndex((item) => item.id === list[oldIndex].id);
// @ts-ignore
const _newIndex = messages.findIndex((item) => item.id === list[newIndex].id);
const tmp = newMessages[_oldIndex];
newMessages[_oldIndex] = newMessages[_newIndex];
newMessages[_newIndex] = tmp;
setMessages(newMessages);
}}
direction={'vertical'}
swap={true}
chosenClass='sortable-chosen'
dragClass='sortable-drag'
swapThreshold={0.6}
animation={200}>
{messages.map((message) => {
const id = message.id;
const onChange = (text: string) => {};
const onFinish = (text: string) => {};
return (
<Message
className='message-item'
content={message.content}
role={message.role as any}
data={message}
id={id}
key={id + String(message.updatedAt)}
onChange={onChange}
onFinish={onFinish}
/>
);
})}
</ReactSortable>
);
}, [messages, list]);
return (
<>
{reactSortable}
<div className='flex py-2 gap-2'>
<Tooltip title='添加新的上下文'>
<IconButton
onClick={() => {
setContextMessage(undefined);
setShowContext(true);
}}>
<Plus className='w-4 h-4' />
</IconButton>
</Tooltip>
<Tooltip title='清理所有上下文'>
<IconButton onClick={onEraser}>
<Eraser className='w-4 h-4' />
</IconButton>
</Tooltip>
</div>
</>
);
};

View File

@@ -0,0 +1,118 @@
import { Copy, Eye, EyeOff, Menu, Plus, Save, Send, Settings, Trash } from 'lucide-react';
import { useChatStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { IconButton } from '@/components/a/button.tsx';
import { Tooltip } from '@/components/a/tooltip.tsx';
import { Divider } from '@/components/a/divider';
export const ModelNav = () => {
const {
id,
messages,
showAllContext,
setShowAllContext,
setShowContext,
setContextMessage,
updateChat,
currentUserModel,
setShowChatSetting,
chat,
setShowSetting,
setShowCopy,
} = useChatStore(
useShallow((state) => {
return {
id: state.id,
messages: state.messages,
showAllContext: state.showAllContext,
setShowAllContext: state.setShowAllContext,
showContext: state.showContext,
setShowContext: state.setShowContext,
setContextMessage: state.setContextMessage,
updateChat: state.updateChat,
currentUserModel: state.currentUserModel,
setShowChatSetting: state.setShowChatSetting,
chat: state.chat,
setShowSetting: state.setShowSetting,
setShowCopy: state.setShowCopy,
};
}),
);
return (
<div className='flex gap-2 items-center'>
<div className='hidden lg:block font-bold text-gray-600'></div>
<div className='flex gap-2 items-center' onClick={() => setShowChatSetting(true)}>
{currentUserModel && (
<Tooltip
title={
<div className='p-2'>
使 [ {currentUserModel.username} ]
<br /> Group: {currentUserModel.model?.group}
<br /> Provider: {currentUserModel.model?.provider}
<br /> Model: {currentUserModel.model?.model}
</div>
}>
<div className='flex gap-2 items-center border border-gray-300 rounded-md p-2 max-w-[200px] cursor-pointer'>
<div className='truncate'>{currentUserModel.model?.model}</div>
</div>
</Tooltip>
)}
{!currentUserModel && <div className='text-sm text-gray-500 cursor-pointer'>Not Selected Model</div>}
</div>
<div className='flex gap-2 items-center'>
{/* <Tooltip title='设置'>
<IconButton
onClick={() => {
setShowSetting(true);
}}>
<Settings className='w-4 h-4' />
</IconButton>
</Tooltip> */}
<Tooltip title={showAllContext ? '显示所有内容' : '不显示隐藏的内容'}>
<IconButton onClick={() => setShowAllContext(!showAllContext)}>
{showAllContext ? <Eye className='w-4 h-4' /> : <EyeOff className='w-4 h-4' />}
</IconButton>
</Tooltip>
<Tooltip title='复制上下文'>
<IconButton
onClick={() => {
setShowCopy(true);
}}>
<Copy className='w-4 h-4' />
</IconButton>
</Tooltip>
<Tooltip title='添加上下文'>
<IconButton
onClick={() => {
setContextMessage(undefined);
setShowContext(true);
}}>
<Plus className='w-4 h-4' />
</IconButton>
</Tooltip>
<Divider orientation='vertical' />
<Tooltip title='保存, 默认只在本地编辑,请手动保存。'>
<IconButton
onClick={() => {
updateChat({
id: id,
data: {
messages: messages,
},
});
}}>
<Save className='w-4 h-4' />
</IconButton>
</Tooltip>
<Tooltip title='发送请求。'>
<IconButton
onClick={() => {
chat();
}}>
<Send className='w-4 h-4' />
</IconButton>
</Tooltip>
</div>
</div>
);
};

View File

@@ -0,0 +1,71 @@
import { useChatStore } from '@/apps/ai-chat/store';
import { MessageProps } from '../MessageList';
import { Edit, EyeOff, Eye, Trash, MonitorOff, Monitor, Copy } from 'lucide-react';
import { useShallow } from 'zustand/shallow';
import { ChastHistoryMessage } from '@/apps/ai-chat/store/type';
import clsx from 'clsx';
import { Tooltip } from '@/components/a/tooltip';
import { copyText } from '@/apps/ai-chat/utils/copy';
import { Confirm } from '@/components/a/confirm';
type ActionsProps = { data: ChastHistoryMessage };
export const Actions = (props: ActionsProps) => {
const { data } = props;
const { deleteMessage, updateMessage, setContextMessage, contextMessage, setShowContext } = useChatStore(
useShallow((state) => {
return {
deleteMessage: state.deleteMessage,
updateMessage: state.updateMessage,
setContextMessage: state.setContextMessage,
contextMessage: state.contextMessage,
setShowContext: state.setShowContext,
};
}),
);
const onEyeChange = () => {
const newData = { ...data, hide: !data.hide };
updateMessage(newData);
};
const onDelete = () => {
data.id && deleteMessage(data.id);
};
const onNoUseChange = () => {
const newData = { ...data, noUse: !data.noUse };
updateMessage(newData);
};
const isEyeHide = data.hide;
const isNoUse = data.noUse;
const EyeIcon = isEyeHide ? Eye : EyeOff;
const MonitorIcon = isNoUse ? Monitor : MonitorOff;
return (
<div className={clsx('flex gap-4 py-2 mt-1 px-2')}>
<Tooltip title='编辑'>
<Edit
className='w-4 h-4 cursor-pointer hover:text-primary'
onClick={() => {
setContextMessage(data);
setShowContext(true);
}}
/>
</Tooltip>
<Tooltip title='复制'>
<Copy className='w-4 h-4 cursor-pointer hover:text-primary' onClick={() => copyText(data.content)} />
</Tooltip>
<Tooltip title={isEyeHide ? '显示' : '隐藏'}>
<EyeIcon className='w-4 h-4 cursor-pointer hover:text-primary' onClick={onEyeChange} />
</Tooltip>
<Tooltip title={isNoUse ? '启用上下文' : '禁用上下文'}>
<MonitorIcon className='w-4 h-4 cursor-pointer hover:text-primary' onClick={onNoUseChange} />
</Tooltip>
<Tooltip title='完全删除'>
<Confirm
title='确认删除'
onOk={() => {
onDelete();
}}>
<Trash className='w-4 h-4 cursor-pointer hover:text-primary' />
</Confirm>
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,153 @@
import { query } from '@/modules/query';
import { Query } from '@kevisual/query';
import { DataOpts } from '@kevisual/query/query';
export class QueryChat {
query: Query;
constructor({ query }: { query: Query }) {
this.query = query;
}
getChatList(opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'get-chat-list',
},
opts,
);
}
getChat(id: string, opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'get-chat',
data: {
id,
},
},
opts,
);
}
updateChat(data: any, opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'update-chat',
data,
},
opts,
);
}
deleteChat(id: string, opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'delete-chat',
data: {
id,
},
},
opts,
);
}
/**
* 获取模型列表
* @param opts
* @returns
*/
getModelList(data?: { usernames?: string[] }, opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'get-model-list',
data,
},
opts,
);
}
/**
* 聊天对话模型
* @param data
* @param chatOpts
* @param opts
* @returns
*/
chat(data: ChatDataOpts, chatOpts: ChatOpts, opts?: DataOpts) {
const { username, model, group, getFull = true } = chatOpts;
if (!username || !model || !group) {
throw new Error('username, model, group is required');
}
return this.query.post(
{
path: 'ai',
key: 'chat',
...chatOpts,
getFull,
data,
},
opts,
);
}
clearConfigCache(opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'clear-cache',
},
opts,
);
}
/**
* 获取聊天使用情况
* @param opts
* @returns
*/
getChatUsage(opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'get-chat-usage',
},
opts,
);
}
/**
* 清除当前用户模型自己的统计
* @param opts
* @returns
*/
clearSelfUsage(opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'clear-chat-limit',
},
opts,
);
}
}
export type ChatDataOpts = {
id?: string;
title?: string;
messages?: any[];
data?: any;
type?: 'temp' | 'keep' | string;
};
export type ChatOpts = {
username: string;
model: string;
/**
* 获取完整消息回复
*/
getFull?: boolean;
group: string;
/**
* openai的参数
*/
options?: any;
};

View File

@@ -0,0 +1,61 @@
import { ChastHistoryMessage } from './type';
export const messagesDemo1: ChastHistoryMessage[] = [
{
role: 'user',
content: '你好',
name: '用户',
},
{
role: 'assistant',
content: '你好我是AI助手很高兴认识你',
name: 'AI',
},
];
export const messagesDemo2: ChastHistoryMessage[] = [
{
role: 'user',
content: '你能帮我解释一下什么是人工智能吗?',
name: '用户',
},
{
role: 'assistant',
content:
'人工智能AI是计算机科学的一个分支致力于创建能够模拟人类智能行为的系统。这些系统可以学习、推理、感知环境、理解语言并解决问题。目前常见的AI应用包括语音助手、推荐系统、自动驾驶和自然语言处理等。需要了解更具体的AI领域吗',
name: 'AI助手',
},
{
role: 'user',
content: '机器学习和深度学习有什么区别?',
name: '用户',
},
{
role: 'assistant',
content:
'机器学习和深度学习的主要区别是:\n\n1. **机器学习**是AI的一个子集使用统计方法让计算机系统通过数据"学习"并改进性能,无需明确编程。常见的机器学习算法包括决策树、支持向量机和随机森林等。\n\n2. **深度学习**是机器学习的一个子集,使用多层神经网络(称为深度神经网络)来模拟人脑的工作方式。它能自动从大量数据中提取特征,特别适合处理图像识别、自然语言处理等复杂任务。\n\n简单来说深度学习是机器学习的一种高级形式通过更复杂的模型结构处理更庞大的数据集。',
name: 'AI助手',
},
{
role: 'user',
content: '你能给我写一个简单的Python函数来计算斐波那契数列吗',
name: '用户',
},
{
role: 'assistant',
content:
'当然这里有两种计算斐波那契数列的Python函数\n\n```python\n# 递归方法(简单但效率较低)\ndef fibonacci_recursive(n):\n if n <= 0:\n return 0\n elif n == 1:\n return 1\n else:\n return fibonacci_recursive(n-1) + fibonacci_recursive(n-2)\n\n# 迭代方法(更高效)\ndef fibonacci_iterative(n):\n if n <= 0:\n return 0\n elif n == 1:\n return 1\n \n a, b = 0, 1\n for _ in range(2, n+1):\n a, b = b, a + b\n return b\n```\n\n迭代方法在处理较大的n时效率更高因为它避免了递归方法中的重复计算问题。',
name: 'AI助手',
},
{
role: 'user',
content: '谢谢你的帮助!今天的天气真不错。',
name: '用户',
},
{
role: 'assistant',
content:
'不客气很高兴能帮到你确实好天气总能让人心情愉快。如果你有任何其他问题无论是关于编程、AI还是其他话题都欢迎随时向我咨询。祝你度过美好的一天',
name: 'AI助手',
},
];

View File

@@ -0,0 +1,91 @@
/**
* message type 是 {role: 'user' | 'assistant' | 'system', content: string, hide: boolean, noUse: boolean}
* @param messages
* @returns
*/
export const generateTextPrompt = (messages) => {
const newMessages = [...messages].filter((message) => !message.hide && !message.noUse) as any;
const lastReverseUserMessageIndex = newMessages.findLastIndex((message) => message.role === 'user');
const text = newMessages
.slice(0, lastReverseUserMessageIndex)
.map((message) => {
return `----\n${message.role}: \n ${message.content}\n----`;
})
.join('\n');
let prompt = `
你是一个ai对话的助手请根据用户的问题和用户的历史对话给出最合适的回答。
用户的问题是:\n
${newMessages[lastReverseUserMessageIndex].content}
用户的历史对话是:
${text} \n
`;
return prompt;
};
/**
* message type 是 {role: 'user' | 'assistant' | 'system', content: string, hide: boolean, noUse: boolean}
* @param messages
* @returns
*/
export const generateText = (messages) => {
const newMessages = [...messages].filter((message) => !message.hide && !message.noUse);
const text = newMessages
.map((message) => {
return '----' + message.role + '\n' + message.content + '\n----';
})
.join('\n');
return text;
};
export const getCodeTemplate = () => {
return `/**
* message type 是 {role: 'user' | 'assistant' | 'system', content: string, hide: boolean, noUse: boolean}
* @param messages
* @returns
*/
export const generateText = (messages) => {
const newMessages = [...messages].filter((message) => !message.hide && !message.noUse);
const text = newMessages
.map((message) => {
return '----' + message.role + '\\n' + message.content + '\\n----';
})
.join('\\n');
return text;
};
`;
};
export const runCode = (code) => {
// 创建一个Blob对象包含要执行的代码
const blob = new Blob([code], { type: 'application/javascript' });
// 将代码转换为URL
const codeUrl = URL.createObjectURL(blob);
try {
// 使用动态import导入代码
return import(/* @vite-ignore */ codeUrl)
.then((module) => {
// 导入成功后释放URL
console.log('module', module);
URL.revokeObjectURL(codeUrl);
return module;
})
.catch((error) => {
// 发生错误时也释放URL
URL.revokeObjectURL(codeUrl);
throw new Error(`代码执行失败: ${error.message}`);
});
} catch (error: any) {
// 捕获任何其他错误
URL.revokeObjectURL(codeUrl);
throw new Error(`无法导入代码: ${error.message}`);
}
};

View File

@@ -0,0 +1,313 @@
import { StoreManager } from '@kevisual/store';
import { useContextKey } from '@kevisual/store/context';
import { StateCreator } from 'zustand';
import { query } from '@/modules/query';
import { useStore, BoundStore } from '@kevisual/store/react';
import { toast } from 'react-toastify';
import { ChastHistoryMessage } from './type';
import { QueryChat } from '../query/chat';
import { CacheStore } from '@kevisual/cache/cache-store';
import { QueryMark } from '@/query/query-mark/query-mark';
export const queryMark = new QueryMark({ query: query, markType: 'chat' });
export const queryChat = new QueryChat({ query });
const dbName = 'chat';
export const store = useContextKey('store', () => {
return new StoreManager();
});
type ChatStore = {
id: string;
setId: (id: string) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
messages: ChastHistoryMessage[];
updateTime: number;
setMessages: (messages: ChastHistoryMessage[], needCache?: boolean) => void;
init: (id: string) => void;
/**
* 删除消息
* @param index 消息索引
*/
deleteMessage: (id: string) => void;
/**
* 清空消息
*/
clearMessage: () => void;
/**
* 更新消息
* @param message 消息
*/
updateMessage: (message: ChastHistoryMessage) => void;
showAllContext: boolean;
setShowAllContext: (showAllContext: boolean) => void;
showChatSetting: boolean;
setShowChatSetting: (showChatSetting: boolean) => void;
showList: boolean;
setShowList: (showList: boolean) => void;
newChat: boolean;
setNewChat: (newChat: boolean) => void;
historyList: any[];
setHistoryList: (historyList: any[]) => void;
chatData: any;
setChatData: (chatData: any) => void;
getChatList: () => Promise<any>;
updateChat: (data: any) => Promise<any>;
deleteChat: (id: string) => Promise<any>;
// 上下文对话框编辑
showContext: boolean;
setShowContext: (showContext: boolean) => void;
contextMessage?: ChastHistoryMessage;
setContextMessage: (contextMessage?: ChastHistoryMessage) => void;
currentUserModel?: { username: string; model: { group: string; provider: string; model: string } };
setCurrentUserModel: (currentUserModel: { username: string; model: { group: string; provider: string; model: string } }) => void;
modelList: any[];
setModelList: (modelList: any[]) => void;
getModelList: (force?: boolean) => Promise<any>;
chat: () => Promise<any>;
clearConfigCache: () => Promise<any>;
modelId: string;
setModelId: (modelId: string) => void;
showSetting: boolean;
setShowSetting: (showSetting: boolean) => void;
showCopy: boolean;
setShowCopy: (showCopy: boolean) => void;
};
export const createChatStore: StateCreator<ChatStore, [], [], ChatStore> = (set, get, store) => {
return {
id: '',
setId: (id: string) => set(() => ({ id })),
loading: false,
setLoading: (loading: boolean) => set(() => ({ loading })),
updateTime: 0,
messages: [],
setMessages: (messages: ChastHistoryMessage[], needCache = true) => {
const id = get().id;
if (needCache) {
const chatData = get().chatData;
if (chatData) {
const data = chatData.data || {};
data.messages = messages;
chatData.data = data;
const cache = new CacheStore({ dbName });
cache.set(id, chatData);
}
}
const now = Date.now();
set(() => ({ updateTime: now }));
set({ messages });
},
init: async (id: string) => {
if (!id) {
return;
}
// clear default
const { setMessages } = get();
setMessages([], false);
const { getModelList, setCurrentUserModel } = get();
await getModelList();
const cache = new CacheStore({ dbName });
const chatData = await cache.get(id);
const currentUserModel = await cache.get('currentUserModel');
if (currentUserModel) {
setCurrentUserModel(currentUserModel);
}
if (chatData) {
const messages = chatData.data?.messages || [];
set(() => ({ messages: messages, chatData }));
}
const res = await queryMark.getMark(id);
if (res.code === 200) {
const resChatData = res.data as any;
if (chatData && resChatData.updatedAt === chatData.updatedAt) {
console.log('no update', 'time is ', resChatData.updatedAt, chatData.updatedAt);
return;
}
console.log('update chat data', resChatData);
get().setMessages(resChatData?.data?.messages || []);
set(() => ({ chatData: resChatData }));
} else {
toast.error(res.message);
}
},
deleteMessage: (id: string) => {
const { messages, setMessages } = get();
const newMessages = messages.filter((m) => m.id !== id);
setMessages(newMessages);
},
clearMessage: () => {
const { setMessages } = get();
setMessages([]);
},
updateMessage: (message: ChastHistoryMessage) => {
const { messages, setMessages } = get();
let isNew = true;
let newMessages = messages.map((m) => {
if (m.id === message.id) {
isNew = false;
return { ...m, ...message, updatedAt: new Date().getTime() };
}
return m;
});
if (isNew) {
newMessages.push(message);
}
setMessages(newMessages);
},
showAllContext: false,
setShowAllContext: (showAllContext: boolean) => set(() => ({ showAllContext })),
showChatSetting: false,
setShowChatSetting: (showChatSetting: boolean) => set(() => ({ showChatSetting })),
showList: false,
setShowList: (showList: boolean) => set(() => ({ showList })),
newChat: false,
setNewChat: (newChat: boolean) => set(() => ({ newChat })),
historyList: [],
setHistoryList: (historyList: any[]) => set(() => ({ historyList })),
getChatList: async () => {
const res = await queryMark.getMarkList();
if (res.code === 200) {
const len = res.data.list.length;
// if (len === 0) {
// set(() => ({ newChat: true }));
// }
set(() => ({ historyList: res.data.list }));
} else {
toast.error(res.message || 'request chat list error');
}
},
updateChat: async (data: any) => {
const res = await queryMark.updateMark(data);
console.log('update chat res', data);
if (res.code === 200) {
toast.success('update success');
get().setChatData(res.data);
get().getChatList();
} else {
toast.error(res.message || 'error');
}
return res;
},
deleteChat: async (id: string) => {
const res = await queryMark.deleteMark(id);
if (res.code === 200) {
toast.success('delete success');
get().getChatList();
} else {
toast.error(res.message || 'error');
}
return res;
},
chatData: {},
setChatData: (chatData: any) => {
const cache = new CacheStore({ dbName });
cache.set(chatData.id, chatData);
set(() => ({ chatData }));
},
showContext: false,
setShowContext: (showContext: boolean) => set(() => ({ showContext })),
contextMessage: {
role: 'user',
content: '',
name: 'user',
},
setContextMessage: (contextMessage?: ChastHistoryMessage) => set(() => ({ contextMessage })),
modelList: [],
setModelList: (modelList: any[]) => set(() => ({ modelList })),
getModelList: async (force?: boolean) => {
const cache = new CacheStore({ dbName });
const modelList = await cache.get('modelList');
if (modelList && !force) {
set(() => ({ modelList }));
return;
}
// const res = await queryChat.getModelList();
// if (res.code === 200) {
// set(() => ({ modelList: res.data.list }));
// cache.set('modelList', res.data.list);
// } else {
// toast.error(res.message || 'error');
// }
},
currentUserModel: undefined,
setCurrentUserModel: (currentUserModel: { username: string; model: { group: string; provider: string; model: string } }) => {
set(() => ({ currentUserModel }));
const cache = new CacheStore({ dbName });
cache.set('currentUserModel', currentUserModel);
},
chat: async () => {
const { id, currentUserModel, messages } = get();
if (!currentUserModel) {
toast.error('请先设置当前用户模型');
return;
}
const { username, model } = currentUserModel;
const { group, provider, model: modelName } = model;
const chatOpts = {
username,
group,
model: modelName,
};
set(() => ({ loading: true }));
const loaded = toast.loading('loading...');
const res = await queryChat.chat({ id, messages }, chatOpts);
toast.dismiss(loaded);
set(() => ({ loading: false }));
console.log('chat res', res);
if (res.code === 200) {
const data = res.data;
const aiChatHistory = data.aiChatHistory;
console.log('aiChatHistory ', data);
if (aiChatHistory) {
const { messages } = aiChatHistory;
get().setChatData(aiChatHistory);
get().setMessages(messages);
console.log('aiChatHistory update', aiChatHistory);
} else if (data.message) {
const message = data.message;
get().updateMessage(message);
}
} else {
toast.error(res.message || 'error');
}
},
clearConfigCache: async () => {
const res = await queryChat.clearConfigCache();
// 清理缓存
if (res.code === 200) {
toast.success('clear config cache success');
setTimeout(() => {
get().getModelList(true);
}, 4000);
} else {
toast.error(res.message || 'error');
}
},
modelId: '',
setModelId: (modelId: string) => set(() => ({ modelId })),
showSetting: false,
setShowSetting: (showSetting: boolean) => set(() => ({ showSetting })),
showCopy: false,
setShowCopy: (showCopy: boolean) => set(() => ({ showCopy })),
};
};
export const useChatStore = useStore as BoundStore<ChatStore>;

View File

@@ -0,0 +1,10 @@
export type ChastHistoryMessage = {
role: string;
content: string;
name: string;
id?: string;
createdAt?: number;
updatedAt?: number;
hide?: boolean;
noUse?: boolean;
};

View File

@@ -0,0 +1,18 @@
import { toast } from 'react-toastify';
// 复制
export const copy = (text: string) => {
navigator.clipboard.writeText(text);
};
export const copyText = (text: string, showToast = true) => {
copy(text);
if (showToast) {
toast.success('复制成功');
}
};
export const copyHtml = (html: string) => {
copy(html);
toast.success('复制成功');
};

View File

@@ -0,0 +1,12 @@
import { customAlphabet } from 'nanoid';
export const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
export const nanoid = customAlphabet(alphabet, 16);
export function uuid() {
return nanoid();
}
export const chatId = () => {
return `chat-${nanoid()}`;
};