generated from template/astro-template
add ai-chat
This commit is contained in:
parent
500b622d9e
commit
e38b8df9f0
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -0,0 +1,3 @@
|
||||
[submodule "packages/markdown-editor"]
|
||||
path = packages/markdown-editor
|
||||
url = git@git.xiongxiao.me:kevisual/markdown-editor.git
|
37
package.json
37
package.json
@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@kevisual/astro-template",
|
||||
"name": "@kevisual/ai-pages",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"basename": "/root/astro-template",
|
||||
"basename": "/root/ai-pages",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"pub": "envision deploy ./dist -k astro-template -v 0.0.1 -u",
|
||||
"pub": "ev deploy ./dist -k ai-pages -v 0.0.1 -u",
|
||||
"git:submodule": "git submodule update --init --recursive",
|
||||
"sn": "pnpm dlx shadcn@latest add "
|
||||
},
|
||||
@ -17,11 +17,11 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.6",
|
||||
"@astrojs/react": "^4.2.7",
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@astrojs/sitemap": "^3.4.0",
|
||||
"@kevisual/query": "^0.0.18",
|
||||
"@kevisual/query-login": "^0.0.5",
|
||||
"@kevisual/cache": "^0.0.3",
|
||||
"@kevisual/query-login": "^0.0.6",
|
||||
"@kevisual/registry": "^0.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
@ -31,30 +31,47 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"astro": "^5.7.13",
|
||||
"astro": "^5.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^25.2.0",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^15.0.12",
|
||||
"marked-highlight": "^2.2.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"pretty-bytes": "^7.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-sortablejs": "^6.1.4",
|
||||
"react-toastify": "^11.0.5",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zustand": "^5.0.4"
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/markdown-editor": "workspace:*",
|
||||
"@kevisual/query": "^0.0.20",
|
||||
"@kevisual/query-awesome": "^0.0.2",
|
||||
"@kevisual/router": "^0.0.21",
|
||||
"@kevisual/store": "^0.0.8",
|
||||
"@kevisual/types": "^0.0.10",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vitejs/plugin-basic-ssl": "^2.0.0",
|
||||
"commander": "^14.0.0",
|
||||
"dotenv": "^16.5.0",
|
||||
|
1619
pnpm-lock.yaml
generated
1619
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
124
src/apps/ai-chat/chat-context/Dialog.tsx
Normal file
124
src/apps/ai-chat/chat-context/Dialog.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
46
src/apps/ai-chat/chat-copy/Dialog.tsx
Normal file
46
src/apps/ai-chat/chat-copy/Dialog.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
151
src/apps/ai-chat/chat-copy/List.tsx
Normal file
151
src/apps/ai-chat/chat-copy/List.tsx
Normal 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>
|
||||
);
|
||||
};
|
41
src/apps/ai-chat/chat-history/Dialog.tsx
Normal file
41
src/apps/ai-chat/chat-history/Dialog.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
117
src/apps/ai-chat/chat-history/List.tsx
Normal file
117
src/apps/ai-chat/chat-history/List.tsx
Normal 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>
|
||||
);
|
||||
};
|
46
src/apps/ai-chat/chat-model-setting/Dialog.tsx
Normal file
46
src/apps/ai-chat/chat-model-setting/Dialog.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
67
src/apps/ai-chat/chat-model-setting/List.tsx
Normal file
67
src/apps/ai-chat/chat-model-setting/List.tsx
Normal 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>
|
||||
);
|
||||
};
|
46
src/apps/ai-chat/chat-setting/Dialog.tsx
Normal file
46
src/apps/ai-chat/chat-setting/Dialog.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
136
src/apps/ai-chat/chat-setting/List.tsx
Normal file
136
src/apps/ai-chat/chat-setting/List.tsx
Normal 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>
|
||||
);
|
||||
};
|
115
src/apps/ai-chat/components/RenderMarkdown.tsx
Normal file
115
src/apps/ai-chat/components/RenderMarkdown.tsx
Normal 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>;
|
||||
};
|
18
src/apps/ai-chat/editor/Editor.tsx
Normal file
18
src/apps/ai-chat/editor/Editor.tsx
Normal 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>;
|
||||
};
|
25
src/apps/ai-chat/editor/suggestion/item.ts
Normal file
25
src/apps/ai-chat/editor/suggestion/item.ts
Normal 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(),
|
||||
},
|
||||
];
|
||||
};
|
14
src/apps/ai-chat/index.css
Normal file
14
src/apps/ai-chat/index.css
Normal 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
115
src/apps/ai-chat/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
199
src/apps/ai-chat/modules/MessageList.tsx
Normal file
199
src/apps/ai-chat/modules/MessageList.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
118
src/apps/ai-chat/modules/ModelNav.tsx
Normal file
118
src/apps/ai-chat/modules/ModelNav.tsx
Normal 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>
|
||||
);
|
||||
};
|
71
src/apps/ai-chat/modules/actions/Actions.tsx
Normal file
71
src/apps/ai-chat/modules/actions/Actions.tsx
Normal 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>
|
||||
);
|
||||
};
|
153
src/apps/ai-chat/query/chat.ts
Normal file
153
src/apps/ai-chat/query/chat.ts
Normal 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;
|
||||
};
|
61
src/apps/ai-chat/store/demo.ts
Normal file
61
src/apps/ai-chat/store/demo.ts
Normal 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助手',
|
||||
},
|
||||
];
|
91
src/apps/ai-chat/store/generte-text.ts
Normal file
91
src/apps/ai-chat/store/generte-text.ts
Normal 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}`);
|
||||
}
|
||||
};
|
313
src/apps/ai-chat/store/index.ts
Normal file
313
src/apps/ai-chat/store/index.ts
Normal 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>;
|
10
src/apps/ai-chat/store/type.ts
Normal file
10
src/apps/ai-chat/store/type.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type ChastHistoryMessage = {
|
||||
role: string;
|
||||
content: string;
|
||||
name: string;
|
||||
id?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
hide?: boolean;
|
||||
noUse?: boolean;
|
||||
};
|
18
src/apps/ai-chat/utils/copy.ts
Normal file
18
src/apps/ai-chat/utils/copy.ts
Normal 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('复制成功');
|
||||
};
|
12
src/apps/ai-chat/utils/uuid.ts
Normal file
12
src/apps/ai-chat/utils/uuid.ts
Normal 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()}`;
|
||||
};
|
@ -4,7 +4,7 @@ export const IconButton: typeof UiButton = (props) => {
|
||||
return <UiButton variant='ghost' size='icon' {...props} className={cn('h-8 w-8 cursor-pointer', props?.className)} />;
|
||||
};
|
||||
|
||||
export const Button: typeof UiButton = (props) => {
|
||||
export const Button = (props: Parameters<typeof UiButton>[0]) => {
|
||||
return <UiButton variant='ghost' {...props} className={cn('cursor-pointer', props?.className)} />;
|
||||
};
|
||||
|
||||
|
@ -14,7 +14,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
type useConfirmOptions = {
|
||||
confrimProps: ConfirmProps;
|
||||
};
|
||||
type Fn = () => void;
|
||||
type Fn = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
export const useConfirm = (opts?: useConfirmOptions) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
type ConfirmOptions = {
|
||||
@ -78,19 +78,21 @@ export const Confirm = (props: ConfirmProps) => {
|
||||
{!props?.footer && (
|
||||
<>
|
||||
<AlertDialogCancel
|
||||
className='cursor-pointer'
|
||||
onClick={(e) => {
|
||||
props?.onCancle?.();
|
||||
props?.onCancle?.(e);
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{props?.onCancelText ?? '取消'}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='cursor-pointer'
|
||||
onClick={(e) => {
|
||||
props?.onOk?.();
|
||||
props?.onOk?.(e);
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{props?.onOkText ?? '确定'}
|
||||
</AlertDialogAction>{' '}
|
||||
</AlertDialogAction>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
|
21
src/components/a/divider.tsx
Normal file
21
src/components/a/divider.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
export const Divider = (props: { orientation?: 'horizontal' | 'vertical' }) => {
|
||||
const { orientation = 'horizontal' } = props;
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
backgroundColor: '#e5e7eb', // 淡灰色分割线
|
||||
...(orientation === 'horizontal'
|
||||
? {
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
}
|
||||
: {
|
||||
alignSelf: 'stretch',
|
||||
width: '1px',
|
||||
display: 'inline-block',
|
||||
margin: '2px 4px',
|
||||
}),
|
||||
};
|
||||
|
||||
return <div style={dividerStyle} role='separator' aria-orientation={orientation} />;
|
||||
};
|
117
src/components/a/drag-modal/index.tsx
Normal file
117
src/components/a/drag-modal/index.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Draggable from 'react-draggable';
|
||||
import { cn as clsxMerge } from '@/lib/utils';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type DragModalProps = {
|
||||
title?: React.ReactNode;
|
||||
content?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
containerClassName?: string;
|
||||
handleClassName?: string;
|
||||
contentClassName?: string;
|
||||
focus?: boolean;
|
||||
/**
|
||||
* 默认大小, 单位为px
|
||||
* width: defaultSize.width || 320
|
||||
* height: defaultSize.height || 400
|
||||
*/
|
||||
defaultSize?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
export const DragModal = (props: DragModalProps) => {
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={dragRef as any}
|
||||
onStop={(e, data) => {
|
||||
// console.log(e, data);
|
||||
}}
|
||||
handle='.handle'
|
||||
grid={[1, 1]}
|
||||
scale={1}
|
||||
bounds='parent'
|
||||
defaultPosition={{
|
||||
x: 0,
|
||||
y: 0,
|
||||
}}>
|
||||
<div
|
||||
className={clsxMerge('absolute top-0 left-0 bg-white rounded-md border border-gray-200 shadow-sm', props.focus ? 'z-30' : '', props.containerClassName)}
|
||||
ref={dragRef}
|
||||
style={props.style}>
|
||||
<div className={clsxMerge('handle cursor-move border-b border-gray-200 py-2 px-4', props.handleClassName)}>{props.title || 'Move'}</div>
|
||||
<Resizable
|
||||
className={clsxMerge('', props.contentClassName)}
|
||||
defaultSize={{
|
||||
width: props.defaultSize?.width || 600,
|
||||
height: props.defaultSize?.height || 400,
|
||||
}}
|
||||
onResizeStop={(e, direction, ref, d) => {
|
||||
// console.log(e, direction, ref, d);
|
||||
}}
|
||||
enable={{
|
||||
bottom: true,
|
||||
right: true,
|
||||
bottomRight: true,
|
||||
}}>
|
||||
{props.content}
|
||||
</Resizable>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
||||
type DragModalTitleProps = {
|
||||
title?: React.ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export const DragModalTitle = (props: DragModalTitleProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsxMerge('flex flex-row items-center justify-between', props.className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.();
|
||||
}}>
|
||||
<div className='text-sm font-medium text-gray-700'>
|
||||
{props.title}
|
||||
{props.children}
|
||||
</div>
|
||||
<div
|
||||
className='text-gray-500 cursor-pointer p-2 hover:bg-gray-100 rounded-md'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClose?.();
|
||||
}}>
|
||||
<X className='w-4 h-4 ' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getComputedHeight = () => {
|
||||
const height = window.innerHeight;
|
||||
const width = window.innerWidth;
|
||||
return { height, width };
|
||||
};
|
||||
|
||||
export const useComputedHeight = () => {
|
||||
const [computedHeight, setComputedHeight] = useState({
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
const height = window.innerHeight;
|
||||
const width = window.innerWidth;
|
||||
setComputedHeight({ height, width });
|
||||
}, []);
|
||||
return computedHeight;
|
||||
};
|
6
src/components/a/input.tsx
Normal file
6
src/components/a/input.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { Input as UIInput } from '@/components/ui/input';
|
||||
|
||||
export type InputProps = { label?: string } & React.ComponentProps<'input'>;
|
||||
export const Input = (props: InputProps) => {
|
||||
return <UIInput {...props} />;
|
||||
};
|
@ -5,10 +5,12 @@ type Option = {
|
||||
label?: string;
|
||||
};
|
||||
type SelectProps = {
|
||||
className?: string;
|
||||
options?: Option[];
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => any;
|
||||
onChange?: (value: any) => any;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
};
|
||||
export const Select = (props: SelectProps) => {
|
||||
const options = props.options || [];
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import React from 'react';
|
||||
|
||||
export const Tooltip = (props: { children?: React.ReactNode; title?: string }) => {
|
||||
export const Tooltip = (props: { children?: React.ReactNode; title?: React.ReactNode }) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<UITooltip>
|
||||
|
115
src/components/ai/RenderMarkdown.tsx
Normal file
115
src/components/ai/RenderMarkdown.tsx
Normal 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>;
|
||||
};
|
@ -6,6 +6,16 @@
|
||||
<head>
|
||||
<meta charset='UTF-8' />
|
||||
<title>AI Pages</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
|
@ -1,4 +1,4 @@
|
||||
# srvice
|
||||
# service
|
||||
|
||||
关于服务器构思分享。
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Query } from '@kevisual/query';
|
||||
import { Query, ClientQuery } from '@kevisual/query/query';
|
||||
|
||||
export const query = new Query();
|
||||
|
||||
export const clientQuery = new Query({ url: '/client/router' });
|
||||
export const clientQuery = new ClientQuery();
|
||||
|
@ -1,8 +1,9 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import Blank from '@/components/html/blank.astro';
|
||||
import { App as AIChat } from '@/apps/ai-chat/index.tsx';
|
||||
---
|
||||
|
||||
<Blank>
|
||||
<div>sdf</div>
|
||||
<AIChat client:only />
|
||||
</Blank>
|
||||
|
25
src/query/kevisual.json
Normal file
25
src/query/kevisual.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://kevisual.xiongxiao.me/root/ai/kevisual/tools/kevisual-sync/schema.json?v=2",
|
||||
"metadata": {
|
||||
"share": "public"
|
||||
},
|
||||
"checkDir": {
|
||||
"src/query": {
|
||||
"url": "https://kevisual.xiongxiao.me/root/ai/code/registry/query",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"syncDirectory": [
|
||||
{
|
||||
"files": [
|
||||
"src/query/**/*"
|
||||
],
|
||||
"ignore": [],
|
||||
"registry": "https://kevisual.xiongxiao.me/root/ai/code/registry",
|
||||
"replace": {
|
||||
"src/": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"sync": {}
|
||||
}
|
21
src/query/query-ai/defines/ai.ts
Normal file
21
src/query/query-ai/defines/ai.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { QueryUtil } from '@/query/index.ts';
|
||||
|
||||
type Message = {
|
||||
role?: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content?: string;
|
||||
name?: string;
|
||||
};
|
||||
export type PostChat = {
|
||||
messages?: Message[];
|
||||
model?: string;
|
||||
group?: string;
|
||||
user?: string;
|
||||
};
|
||||
|
||||
export const appDefine = QueryUtil.create({
|
||||
chat: {
|
||||
path: 'ai',
|
||||
key: 'chat',
|
||||
description: '与 AI 进行对话, 调用 GPT 的AI 服务,生成结果,并返回。',
|
||||
},
|
||||
});
|
25
src/query/query-ai/query-ai.ts
Normal file
25
src/query/query-ai/query-ai.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { appDefine } from './defines/ai.ts';
|
||||
import { PostChat } from './defines/ai.ts';
|
||||
|
||||
import { BaseQuery, DataOpts, Query } from '@kevisual/query/query';
|
||||
|
||||
export { appDefine };
|
||||
|
||||
export class QueryApp<T extends Query = Query> extends BaseQuery<T, typeof appDefine> {
|
||||
constructor(opts?: { query: T }) {
|
||||
super({
|
||||
...opts,
|
||||
query: opts?.query!,
|
||||
queryDefine: appDefine,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 与 AI 进行对话, 调用 GPT 的AI 服务,生成结果,并返回。
|
||||
* @param data
|
||||
* @param opts
|
||||
* @returns
|
||||
*/
|
||||
postChat(data: PostChat, opts?: DataOpts) {
|
||||
return this.chain('chat').post(data, opts);
|
||||
}
|
||||
}
|
204
src/query/query-login/login-cache.ts
Normal file
204
src/query/query-login/login-cache.ts
Normal file
@ -0,0 +1,204 @@
|
||||
export interface Cache {
|
||||
/**
|
||||
* @update 获取缓存
|
||||
*/
|
||||
get(key: string): Promise<any>;
|
||||
/**
|
||||
* @update 设置缓存
|
||||
*/
|
||||
set(key: string, value: any): Promise<any>;
|
||||
/**
|
||||
* @update 删除缓存
|
||||
*/
|
||||
del(): Promise<void>;
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
init?: () => Promise<any>;
|
||||
}
|
||||
type User = {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
id?: string;
|
||||
needChangePassword?: boolean;
|
||||
orgs?: string[];
|
||||
type?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type CacheLoginUser = {
|
||||
user?: User;
|
||||
id?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
type CacheLogin = {
|
||||
loginUsers: CacheLoginUser[];
|
||||
} & CacheLoginUser;
|
||||
|
||||
export type CacheStore<T = Cache> = {
|
||||
name: string;
|
||||
/**
|
||||
* 缓存数据
|
||||
* @important 需要先调用init
|
||||
*/
|
||||
cacheData: CacheLogin;
|
||||
/**
|
||||
* 实际操作的cache, 需要先调用init
|
||||
*/
|
||||
cache: T;
|
||||
|
||||
/**
|
||||
* 设置当前用户
|
||||
*/
|
||||
setLoginUser(user: CacheLoginUser): Promise<void>;
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
getCurrentUser(): Promise<User>;
|
||||
/**
|
||||
* 获取当前用户列表
|
||||
*/
|
||||
getCurrentUserList(): Promise<CacheLoginUser[]>;
|
||||
/**
|
||||
* 获取缓存的refreshToken
|
||||
*/
|
||||
getRefreshToken(): Promise<string>;
|
||||
/**
|
||||
* 获取缓存的accessToken
|
||||
*/
|
||||
getAccessToken(): Promise<string>;
|
||||
/**
|
||||
* 清除当前用户
|
||||
*/
|
||||
clearCurrentUser(): Promise<void>;
|
||||
/**
|
||||
* 清除所有用户
|
||||
*/
|
||||
clearAll(): Promise<void>;
|
||||
|
||||
getValue(): Promise<CacheLogin>;
|
||||
setValue(value: CacheLogin): Promise<CacheLogin>;
|
||||
delValue(): Promise<void>;
|
||||
init(): Promise<any>;
|
||||
};
|
||||
|
||||
export type LoginCacheStoreOpts = {
|
||||
name: string;
|
||||
cache: Cache;
|
||||
};
|
||||
export class LoginCacheStore implements CacheStore<any> {
|
||||
cache: Cache;
|
||||
name: string;
|
||||
cacheData: CacheLogin;
|
||||
constructor(opts: LoginCacheStoreOpts) {
|
||||
if (!opts.cache) {
|
||||
throw new Error('cache is required');
|
||||
}
|
||||
// @ts-ignore
|
||||
this.cache = opts.cache;
|
||||
this.cacheData = {
|
||||
loginUsers: [],
|
||||
user: undefined,
|
||||
id: undefined,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
};
|
||||
this.name = opts.name;
|
||||
}
|
||||
/**
|
||||
* 设置缓存
|
||||
* @param key
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
async setValue(value: CacheLogin) {
|
||||
await this.cache.set(this.name, value);
|
||||
this.cacheData = value;
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* 删除缓存
|
||||
*/
|
||||
async delValue() {
|
||||
await this.cache.del();
|
||||
}
|
||||
getValue(): Promise<CacheLogin> {
|
||||
return this.cache.get(this.name);
|
||||
}
|
||||
/**
|
||||
* 初始化,设置默认值
|
||||
*/
|
||||
async init() {
|
||||
const defaultData = {
|
||||
loginUsers: [],
|
||||
user: null,
|
||||
id: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
};
|
||||
if (this.cache.init) {
|
||||
try {
|
||||
const cacheData = await this.cache.init();
|
||||
this.cacheData = cacheData || defaultData;
|
||||
} catch (error) {
|
||||
console.log('cacheInit error', error);
|
||||
}
|
||||
} else {
|
||||
this.cacheData = (await this.getValue()) || defaultData;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 设置当前用户
|
||||
* @param user
|
||||
*/
|
||||
async setLoginUser(user: CacheLoginUser) {
|
||||
const has = this.cacheData.loginUsers.find((u) => u.id === user.id);
|
||||
if (has) {
|
||||
this.cacheData.loginUsers = this.cacheData?.loginUsers?.filter((u) => u?.id && u.id !== user.id);
|
||||
}
|
||||
this.cacheData.loginUsers.push(user);
|
||||
this.cacheData.user = user.user;
|
||||
this.cacheData.id = user.id;
|
||||
this.cacheData.accessToken = user.accessToken;
|
||||
this.cacheData.refreshToken = user.refreshToken;
|
||||
await this.setValue(this.cacheData);
|
||||
}
|
||||
|
||||
getCurrentUser(): Promise<CacheLoginUser> {
|
||||
const cacheData = this.cacheData;
|
||||
return Promise.resolve(cacheData.user!);
|
||||
}
|
||||
getCurrentUserList(): Promise<CacheLoginUser[]> {
|
||||
return Promise.resolve(this.cacheData.loginUsers.filter((u) => u?.id));
|
||||
}
|
||||
getRefreshToken(): Promise<string> {
|
||||
const cacheData = this.cacheData;
|
||||
return Promise.resolve(cacheData.refreshToken || '');
|
||||
}
|
||||
getAccessToken(): Promise<string> {
|
||||
const cacheData = this.cacheData;
|
||||
return Promise.resolve(cacheData.accessToken || '');
|
||||
}
|
||||
|
||||
async clearCurrentUser() {
|
||||
const user = await this.getCurrentUser();
|
||||
const has = this.cacheData.loginUsers.find((u) => u.id === user.id);
|
||||
if (has) {
|
||||
this.cacheData.loginUsers = this.cacheData?.loginUsers?.filter((u) => u?.id && u.id !== user.id);
|
||||
}
|
||||
this.cacheData.user = undefined;
|
||||
this.cacheData.id = undefined;
|
||||
this.cacheData.accessToken = undefined;
|
||||
this.cacheData.refreshToken = undefined;
|
||||
await this.setValue(this.cacheData);
|
||||
}
|
||||
async clearAll() {
|
||||
this.cacheData.loginUsers = [];
|
||||
this.cacheData.user = undefined;
|
||||
this.cacheData.id = undefined;
|
||||
this.cacheData.accessToken = undefined;
|
||||
this.cacheData.refreshToken = undefined;
|
||||
await this.setValue(this.cacheData);
|
||||
}
|
||||
}
|
132
src/query/query-login/login-node-cache.ts
Normal file
132
src/query/query-login/login-node-cache.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { Cache } from './login-cache.ts';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { readFileSync, writeFileSync, accessSync } from 'node:fs';
|
||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
export const fileExists = async (
|
||||
filePath: string,
|
||||
{ createIfNotExists = true, isFile = true, isDir = false }: { createIfNotExists?: boolean; isFile?: boolean; isDir?: boolean } = {},
|
||||
) => {
|
||||
try {
|
||||
accessSync(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (createIfNotExists && isDir) {
|
||||
await mkdir(filePath, { recursive: true });
|
||||
return true;
|
||||
} else if (createIfNotExists && isFile) {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const readConfigFile = (filePath: string) => {
|
||||
try {
|
||||
const data = readFileSync(filePath, 'utf-8');
|
||||
const jsonData = JSON.parse(data);
|
||||
return jsonData;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
export const writeConfigFile = (filePath: string, data: any) => {
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
};
|
||||
export const getHostName = () => {
|
||||
const configDir = join(homedir(), '.config', 'envision');
|
||||
const configFile = join(configDir, 'config.json');
|
||||
const config = readConfigFile(configFile);
|
||||
const baseURL = config.baseURL || 'https://kevisual.cn';
|
||||
const hostname = new URL(baseURL).hostname;
|
||||
return hostname;
|
||||
};
|
||||
export class StorageNode implements Storage {
|
||||
cacheData: any;
|
||||
filePath: string;
|
||||
constructor() {
|
||||
this.cacheData = {};
|
||||
const configDir = join(homedir(), '.config', 'envision');
|
||||
const hostname = getHostName();
|
||||
this.filePath = join(configDir, 'config', `${hostname}-storage.json`);
|
||||
fileExists(this.filePath, { isFile: true });
|
||||
}
|
||||
async loadCache() {
|
||||
const filePath = this.filePath;
|
||||
try {
|
||||
const data = await readConfigFile(filePath);
|
||||
this.cacheData = data;
|
||||
} catch (error) {
|
||||
this.cacheData = {};
|
||||
await writeFile(filePath, JSON.stringify(this.cacheData, null, 2));
|
||||
}
|
||||
}
|
||||
get length() {
|
||||
return Object.keys(this.cacheData).length;
|
||||
}
|
||||
getItem(key: string) {
|
||||
return this.cacheData[key];
|
||||
}
|
||||
setItem(key: string, value: any) {
|
||||
this.cacheData[key] = value;
|
||||
writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2));
|
||||
}
|
||||
removeItem(key: string) {
|
||||
delete this.cacheData[key];
|
||||
writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2));
|
||||
}
|
||||
clear() {
|
||||
this.cacheData = {};
|
||||
writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2));
|
||||
}
|
||||
key(index: number) {
|
||||
return Object.keys(this.cacheData)[index];
|
||||
}
|
||||
}
|
||||
export class LoginNodeCache implements Cache {
|
||||
filepath: string;
|
||||
|
||||
constructor(filepath?: string) {
|
||||
this.filepath = filepath || join(homedir(), '.config', 'envision', 'config', `${getHostName()}-login.json`);
|
||||
fileExists(this.filepath, { isFile: true });
|
||||
}
|
||||
async get(_key: string) {
|
||||
try {
|
||||
const filePath = this.filepath;
|
||||
const data = readConfigFile(filePath);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.log('get error', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
async set(_key: string, value: any) {
|
||||
try {
|
||||
const data = readConfigFile(this.filepath);
|
||||
const newData = { ...data, ...value };
|
||||
writeConfigFile(this.filepath, newData);
|
||||
} catch (error) {
|
||||
console.log('set error', error);
|
||||
}
|
||||
}
|
||||
async del() {
|
||||
await unlink(this.filepath);
|
||||
}
|
||||
async loadCache(filePath: string) {
|
||||
try {
|
||||
const data = await readFile(filePath, 'utf-8');
|
||||
const jsonData = JSON.parse(data);
|
||||
return jsonData;
|
||||
} catch (error) {
|
||||
// console.log('loadCache error', error);
|
||||
console.log('create new cache file:', filePath);
|
||||
const defaultData = { loginUsers: [] };
|
||||
writeConfigFile(filePath, defaultData);
|
||||
return defaultData;
|
||||
}
|
||||
}
|
||||
async init() {
|
||||
return await this.loadCache(this.filepath);
|
||||
}
|
||||
}
|
12
src/query/query-login/query-login-browser.ts
Normal file
12
src/query/query-login/query-login-browser.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { QueryLogin, QueryLoginOpts } from './query-login.ts';
|
||||
import { MyCache } from '@kevisual/cache';
|
||||
type QueryLoginNodeOptsWithoutCache = Omit<QueryLoginOpts, 'cache'>;
|
||||
|
||||
export class QueryLoginBrowser extends QueryLogin {
|
||||
constructor(opts: QueryLoginNodeOptsWithoutCache) {
|
||||
super({
|
||||
...opts,
|
||||
cache: new MyCache('login'),
|
||||
});
|
||||
}
|
||||
}
|
14
src/query/query-login/query-login-node.ts
Normal file
14
src/query/query-login/query-login-node.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { QueryLogin, QueryLoginOpts } from './query-login.ts';
|
||||
import { LoginNodeCache, StorageNode } from './login-node-cache.ts';
|
||||
type QueryLoginNodeOptsWithoutCache = Omit<QueryLoginOpts, 'cache'>;
|
||||
export const storage = new StorageNode();
|
||||
await storage.loadCache();
|
||||
export class QueryLoginNode extends QueryLogin {
|
||||
constructor(opts: QueryLoginNodeOptsWithoutCache) {
|
||||
super({
|
||||
...opts,
|
||||
storage,
|
||||
cache: new LoginNodeCache(),
|
||||
});
|
||||
}
|
||||
}
|
419
src/query/query-login/query-login.ts
Normal file
419
src/query/query-login/query-login.ts
Normal file
@ -0,0 +1,419 @@
|
||||
import { Query, BaseQuery } from '@kevisual/query';
|
||||
import type { Result, DataOpts } from '@kevisual/query/query';
|
||||
import { setBaseResponse } from '@kevisual/query/query';
|
||||
import { LoginCacheStore, CacheStore } from './login-cache.ts';
|
||||
import { Cache } from './login-cache.ts';
|
||||
|
||||
export type QueryLoginOpts = {
|
||||
query?: Query;
|
||||
isBrowser?: boolean;
|
||||
onLoad?: () => void;
|
||||
storage?: Storage;
|
||||
cache: Cache;
|
||||
};
|
||||
export type QueryLoginData = {
|
||||
username?: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
};
|
||||
export type QueryLoginResult = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export class QueryLogin extends BaseQuery {
|
||||
/**
|
||||
* query login cache, 非实际操作, 一个cache的包裹模块
|
||||
*/
|
||||
cacheStore: CacheStore;
|
||||
isBrowser: boolean;
|
||||
load?: boolean;
|
||||
storage: Storage;
|
||||
onLoad?: () => void;
|
||||
|
||||
constructor(opts?: QueryLoginOpts) {
|
||||
super({
|
||||
query: opts?.query || new Query(),
|
||||
});
|
||||
this.cacheStore = new LoginCacheStore({ name: 'login', cache: opts.cache });
|
||||
this.isBrowser = opts?.isBrowser ?? true;
|
||||
this.init();
|
||||
this.onLoad = opts?.onLoad;
|
||||
this.storage = opts?.storage || localStorage;
|
||||
}
|
||||
setQuery(query: Query) {
|
||||
this.query = query;
|
||||
}
|
||||
private async init() {
|
||||
await this.cacheStore.init();
|
||||
this.load = true;
|
||||
this.onLoad?.();
|
||||
}
|
||||
async post<T = any>(data: any, opts?: DataOpts) {
|
||||
try {
|
||||
return this.query.post<T>({ path: 'user', ...data }, opts);
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
return {
|
||||
code: 400,
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 登录,
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
async login(data: QueryLoginData) {
|
||||
const res = await this.post<QueryLoginResult>({ key: 'login', ...data });
|
||||
if (res.code === 200) {
|
||||
const { accessToken, refreshToken } = res?.data || {};
|
||||
this.storage.setItem('token', accessToken || '');
|
||||
await this.beforeSetLoginUser({ accessToken, refreshToken });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
/**
|
||||
* 手机号登录
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
async loginByCode(data: { phone: string; code: string }) {
|
||||
const res = await this.post<QueryLoginResult>({ path: 'sms', key: 'login', data });
|
||||
if (res.code === 200) {
|
||||
const { accessToken, refreshToken } = res?.data || {};
|
||||
this.storage.setItem('token', accessToken || '');
|
||||
await this.beforeSetLoginUser({ accessToken, refreshToken });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
/**
|
||||
* 设置token
|
||||
* @param token
|
||||
*/
|
||||
async setLoginToken(token: { accessToken: string; refreshToken: string }) {
|
||||
const { accessToken, refreshToken } = token;
|
||||
this.storage.setItem('token', accessToken || '');
|
||||
await this.beforeSetLoginUser({ accessToken, refreshToken });
|
||||
}
|
||||
async loginByWechat(data: { code: string }) {
|
||||
const res = await this.post<QueryLoginResult>({ path: 'wx', key: 'open-login', code: data.code });
|
||||
if (res.code === 200) {
|
||||
const { accessToken, refreshToken } = res?.data || {};
|
||||
this.storage.setItem('token', accessToken || '');
|
||||
await this.beforeSetLoginUser({ accessToken, refreshToken });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
/**
|
||||
* 检测微信登录,登陆成功后,调用onSuccess,否则调用onError
|
||||
* @param param0
|
||||
*/
|
||||
async checkWechat({ onSuccess, onError }: { onSuccess?: (res: QueryLoginResult) => void; onError?: (res: any) => void }) {
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
if (code && state) {
|
||||
const res = await this.loginByWechat({ code });
|
||||
if (res.code === 200) {
|
||||
onSuccess?.(res.data);
|
||||
} else {
|
||||
onError?.(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 登陆成功,需要获取用户信息进行缓存
|
||||
* @param param0
|
||||
*/
|
||||
async beforeSetLoginUser({ accessToken, refreshToken, check401 }: { accessToken?: string; refreshToken?: string; check401?: boolean }) {
|
||||
if (accessToken && refreshToken) {
|
||||
const resUser = await this.getMe(accessToken, check401);
|
||||
if (resUser.code === 200) {
|
||||
const user = resUser.data;
|
||||
if (user) {
|
||||
this.cacheStore.setLoginUser({
|
||||
user,
|
||||
id: user.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
} else {
|
||||
console.error('登录失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 刷新token
|
||||
* @param refreshToken
|
||||
* @returns
|
||||
*/
|
||||
async queryRefreshToken(refreshToken?: string) {
|
||||
const _refreshToken = refreshToken || this.cacheStore.getRefreshToken();
|
||||
let data = { refreshToken: _refreshToken };
|
||||
if (!_refreshToken) {
|
||||
await this.cacheStore.clearCurrentUser();
|
||||
return {
|
||||
code: 401,
|
||||
message: '请先登录',
|
||||
data: {} as any,
|
||||
};
|
||||
}
|
||||
return this.post(
|
||||
{ key: 'refreshToken', data },
|
||||
{
|
||||
afterResponse: async (response, ctx) => {
|
||||
setBaseResponse(response);
|
||||
return response as any;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 检查401错误,并刷新token, 如果refreshToken存在,则刷新token, 否则返回401
|
||||
* 拦截请求,请使用run401Action, 不要直接使用 afterCheck401ToRefreshToken
|
||||
* @param response
|
||||
* @param ctx
|
||||
* @param refetch
|
||||
* @returns
|
||||
*/
|
||||
async afterCheck401ToRefreshToken(response: Result, ctx?: { req?: any; res?: any; fetch?: any }, refetch?: boolean) {
|
||||
const that = this;
|
||||
if (response?.code === 401) {
|
||||
const hasRefreshToken = await that.cacheStore.getRefreshToken();
|
||||
if (hasRefreshToken) {
|
||||
const res = await that.queryRefreshToken(hasRefreshToken);
|
||||
if (res.code === 200) {
|
||||
const { accessToken, refreshToken } = res?.data || {};
|
||||
that.storage.setItem('token', accessToken || '');
|
||||
await that.beforeSetLoginUser({ accessToken, refreshToken, check401: false });
|
||||
if (refetch && ctx && ctx.req && ctx.req.url && ctx.fetch) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const url = ctx.req?.url;
|
||||
const body = ctx.req?.body;
|
||||
const headers = ctx.req?.headers;
|
||||
const res = await ctx.fetch(url, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
headers: { ...headers, Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
setBaseResponse(res);
|
||||
return res;
|
||||
}
|
||||
} else {
|
||||
that.storage.removeItem('token');
|
||||
await that.cacheStore.clearCurrentUser();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
return response as any;
|
||||
}
|
||||
/**
|
||||
* 一个简单的401处理, 如果401,则刷新token, 如果refreshToken不存在,则返回401
|
||||
* refetch 是否重新请求, 会有bug,无限循环,按需要使用
|
||||
* TODO:
|
||||
* @param response
|
||||
* @param ctx
|
||||
* @param opts
|
||||
* @returns
|
||||
*/
|
||||
async run401Action(
|
||||
response: Result,
|
||||
ctx?: { req?: any; res?: any; fetch?: any },
|
||||
opts?: {
|
||||
/**
|
||||
* 是否重新请求, 会有bug,无限循环,按需要使用
|
||||
*/
|
||||
refetch?: boolean;
|
||||
/**
|
||||
* check之后的回调
|
||||
*/
|
||||
afterCheck?: (res: Result) => any;
|
||||
/**
|
||||
* 401处理后, 还是401, 则回调
|
||||
*/
|
||||
afterAlso401?: (res: Result) => any;
|
||||
},
|
||||
) {
|
||||
const that = this;
|
||||
const refetch = opts?.refetch ?? false;
|
||||
if (response?.code === 401) {
|
||||
if (that.query.stop === true) {
|
||||
return { code: 500, success: false, message: 'refresh token loading...' };
|
||||
}
|
||||
that.query.stop = true;
|
||||
const res = await that.afterCheck401ToRefreshToken(response, ctx, refetch);
|
||||
that.query.stop = false;
|
||||
opts?.afterCheck?.(res);
|
||||
if (res.code === 401) {
|
||||
opts?.afterAlso401?.(res);
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
return response as any;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param token
|
||||
* @returns
|
||||
*/
|
||||
async getMe(token?: string, check401: boolean = true) {
|
||||
const _token = token || this.storage.getItem('token');
|
||||
const that = this;
|
||||
return that.post(
|
||||
{ key: 'me' },
|
||||
{
|
||||
beforeRequest: async (config) => {
|
||||
if (config.headers) {
|
||||
config.headers['Authorization'] = `Bearer ${_token}`;
|
||||
}
|
||||
if (!_token) {
|
||||
// TODO: 取消请求,因为没有登陆
|
||||
}
|
||||
return config;
|
||||
},
|
||||
afterResponse: async (response, ctx) => {
|
||||
if (response?.code === 401 && check401 && !token) {
|
||||
return await that.afterCheck401ToRefreshToken(response, ctx);
|
||||
}
|
||||
return response as any;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 检查本地用户,如果本地用户存在,则返回本地用户,否则返回null
|
||||
* @returns
|
||||
*/
|
||||
async checkLocalUser() {
|
||||
const user = await this.cacheStore.getCurrentUser();
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 检查本地token是否存在,简单的判断是否已经属于登陆状态
|
||||
* @returns
|
||||
*/
|
||||
async checkLocalToken() {
|
||||
const token = this.storage.getItem('token');
|
||||
return !!token;
|
||||
}
|
||||
/**
|
||||
* 请求更新,切换用户, 使用switchUser
|
||||
* @param username
|
||||
* @returns
|
||||
*/
|
||||
private async postSwitchUser(username: string) {
|
||||
return this.post({ key: 'switchCheck', data: { username } });
|
||||
}
|
||||
/**
|
||||
* 切换用户
|
||||
* @param username
|
||||
* @returns
|
||||
*/
|
||||
async switchUser(username: string) {
|
||||
const localUserList = await this.cacheStore.getCurrentUserList();
|
||||
const user = localUserList.find((userItem) => userItem.user.username === username);
|
||||
if (user) {
|
||||
this.storage.setItem('token', user.accessToken || '');
|
||||
await this.beforeSetLoginUser({ accessToken: user.accessToken, refreshToken: user.refreshToken });
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
accessToken: user.accessToken,
|
||||
refreshToken: user.refreshToken,
|
||||
},
|
||||
success: true,
|
||||
message: '切换用户成功',
|
||||
};
|
||||
}
|
||||
const res = await this.postSwitchUser(username);
|
||||
|
||||
if (res.code === 200) {
|
||||
const { accessToken, refreshToken } = res?.data || {};
|
||||
this.storage.setItem('token', accessToken || '');
|
||||
await this.beforeSetLoginUser({ accessToken, refreshToken });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
/**
|
||||
* 退出登陆,去掉token, 并删除缓存
|
||||
* @returns
|
||||
*/
|
||||
async logout() {
|
||||
this.storage.removeItem('token');
|
||||
const users = await this.cacheStore.getCurrentUserList();
|
||||
const tokens = users
|
||||
.map((user) => {
|
||||
return user?.accessToken;
|
||||
})
|
||||
.filter(Boolean);
|
||||
this.cacheStore.delValue();
|
||||
return this.post<Result>({ key: 'logout', data: { tokens } });
|
||||
}
|
||||
/**
|
||||
* 检查用户名的组,这个用户是否存在
|
||||
* @param username
|
||||
* @returns
|
||||
*/
|
||||
async hasUser(username: string) {
|
||||
const that = this;
|
||||
return this.post<Result>(
|
||||
{
|
||||
path: 'org',
|
||||
key: 'hasUser',
|
||||
data: {
|
||||
username,
|
||||
},
|
||||
},
|
||||
{
|
||||
afterResponse: async (response, ctx) => {
|
||||
if (response?.code === 401) {
|
||||
const res = await that.afterCheck401ToRefreshToken(response, ctx, true);
|
||||
return res;
|
||||
}
|
||||
return response as any;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 检查登录状态
|
||||
* @param token
|
||||
* @returns
|
||||
*/
|
||||
async checkLoginStatus(token: string) {
|
||||
const res = await this.post({
|
||||
path: 'user',
|
||||
key: 'checkLoginStatus',
|
||||
loginToken: token,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
const accessToken = res.data?.accessToken;
|
||||
this.storage.setItem('token', accessToken || '');
|
||||
await this.beforeSetLoginUser({ accessToken, refreshToken: res.data?.refreshToken });
|
||||
return res;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* 使用web登录,创建url地址, 需要MD5和jsonwebtoken
|
||||
*/
|
||||
loginWithWeb(baseURL: string, { MD5, jsonwebtoken }: { MD5: any; jsonwebtoken: any }) {
|
||||
const randomId = Math.random().toString(36).substring(2, 15);
|
||||
const timestamp = Date.now();
|
||||
const tokenSecret = 'xiao' + randomId;
|
||||
const sign = MD5(`${tokenSecret}${timestamp}`).toString();
|
||||
const token = jsonwebtoken.sign({ randomId, timestamp, sign }, tokenSecret, {
|
||||
// 10分钟过期
|
||||
expiresIn: 60 * 10, // 10分钟
|
||||
});
|
||||
const url = `${baseURL}/api/router?path=user&key=webLogin&p&loginToken=${token}&sign=${sign}&randomId=${randomId}`;
|
||||
return { url, token, tokenSecret };
|
||||
}
|
||||
}
|
154
src/query/query-mark/query-mark.ts
Normal file
154
src/query/query-mark/query-mark.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { Query } from '@kevisual/query';
|
||||
import type { Result, DataOpts } from '@kevisual/query/query';
|
||||
|
||||
export type SimpleObject = Record<string, any>;
|
||||
export const markType = ['simple', 'md', 'mdx', 'wallnote', 'excalidraw', 'chat'] as const;
|
||||
export type MarkType = (typeof markType)[number];
|
||||
export type MarkData = {
|
||||
nodes?: any[];
|
||||
edges?: any[];
|
||||
elements?: any[];
|
||||
permission?: any;
|
||||
|
||||
[key: string]: any;
|
||||
};
|
||||
export type Mark = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
markType: MarkType;
|
||||
link: string;
|
||||
data?: MarkData;
|
||||
uid: string;
|
||||
puid: string;
|
||||
summary: string;
|
||||
thumbnail?: string;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
};
|
||||
export type ShowMarkPick = Pick<Mark, 'id' | 'title' | 'description' | 'summary' | 'link' | 'tags' | 'thumbnail' | 'updatedAt'>;
|
||||
|
||||
export type SearchOpts = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
sort?: string; // DESC, ASC
|
||||
markType?: MarkType; // 类型
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type QueryMarkOpts<T extends SimpleObject = SimpleObject> = {
|
||||
query?: Query;
|
||||
isBrowser?: boolean;
|
||||
onLoad?: () => void;
|
||||
} & T;
|
||||
|
||||
export type ResultMarkList = {
|
||||
list: Mark[];
|
||||
pagination: {
|
||||
pageSize: number;
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
export type QueryMarkData = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
export type QueryMarkResult = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export class QueryMarkBase<T extends SimpleObject = SimpleObject> {
|
||||
query: Query;
|
||||
isBrowser: boolean;
|
||||
load?: boolean;
|
||||
storage?: Storage;
|
||||
onLoad?: () => void;
|
||||
|
||||
constructor(opts?: QueryMarkOpts<T>) {
|
||||
this.query = opts?.query || new Query();
|
||||
this.isBrowser = opts?.isBrowser ?? true;
|
||||
this.init();
|
||||
this.onLoad = opts?.onLoad;
|
||||
}
|
||||
setQuery(query: Query) {
|
||||
this.query = query;
|
||||
}
|
||||
private async init() {
|
||||
this.load = true;
|
||||
this.onLoad?.();
|
||||
}
|
||||
|
||||
async post<T = Result<any>>(data: any, opts?: DataOpts): Promise<T> {
|
||||
try {
|
||||
return this.query.post({ path: 'mark', ...data }, opts) as Promise<T>;
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
return {
|
||||
code: 400,
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
async getMarkList(search: SearchOpts, opts?: DataOpts) {
|
||||
return this.post<Result<ResultMarkList>>({ key: 'list', ...search }, opts);
|
||||
}
|
||||
|
||||
async getMark(id: string, opts?: DataOpts) {
|
||||
return this.post<Result<Mark>>({ key: 'get', id }, opts);
|
||||
}
|
||||
async getVersion(id: string, opts?: DataOpts) {
|
||||
return this.post<Result<{ version: number; id: string }>>({ key: 'getVersion', id }, opts);
|
||||
}
|
||||
/**
|
||||
* 检查版本
|
||||
* 当需要更新时,返回true
|
||||
* @param id
|
||||
* @param version
|
||||
* @param opts
|
||||
* @returns
|
||||
*/
|
||||
async checkVersion(id: string, version?: number, opts?: DataOpts) {
|
||||
if (!version) {
|
||||
return true;
|
||||
}
|
||||
const res = await this.getVersion(id, opts);
|
||||
if (res.code === 200) {
|
||||
if (res.data!.version > version) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateMark(data: any, opts?: DataOpts) {
|
||||
return this.post<Result<Mark>>({ key: 'update', data }, opts);
|
||||
}
|
||||
|
||||
async deleteMark(id: string, opts?: DataOpts) {
|
||||
return this.post<Result<Mark>>({ key: 'delete', id }, opts);
|
||||
}
|
||||
}
|
||||
export class QueryMark extends QueryMarkBase<SimpleObject> {
|
||||
markType: string;
|
||||
constructor(opts?: QueryMarkOpts & { markType?: MarkType }) {
|
||||
super(opts);
|
||||
this.markType = opts?.markType || 'simple';
|
||||
}
|
||||
async getMarkList(search?: SearchOpts, opts?: DataOpts) {
|
||||
return this.post({ key: 'list', ...search, markType: this.markType }, opts);
|
||||
}
|
||||
async updateMark(data: any, opts?: DataOpts) {
|
||||
if (!data.id) {
|
||||
data.markType = this.markType || 'simple';
|
||||
}
|
||||
return super.updateMark(data, opts);
|
||||
}
|
||||
}
|
27
src/query/query-shop/defines/query-shop-define.ts
Normal file
27
src/query/query-shop/defines/query-shop-define.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { QueryUtil } from '@/query/index.ts';
|
||||
|
||||
export const shopDefine = QueryUtil.create({
|
||||
getRegistry: {
|
||||
path: 'shop',
|
||||
key: 'get-registry',
|
||||
description: '获取应用商店注册表信息',
|
||||
},
|
||||
|
||||
listInstalled: {
|
||||
path: 'shop',
|
||||
key: 'list-installed',
|
||||
description: '列出当前已安装的所有应用',
|
||||
},
|
||||
|
||||
install: {
|
||||
path: 'shop',
|
||||
key: 'install',
|
||||
description: '安装指定的应用,可以指定 id、type、force 和 yes 参数',
|
||||
},
|
||||
|
||||
uninstall: {
|
||||
path: 'shop',
|
||||
key: 'uninstall',
|
||||
description: '卸载指定的应用,可以指定 id 和 type 参数',
|
||||
},
|
||||
});
|
17
src/query/query-shop/query-shop.ts
Normal file
17
src/query/query-shop/query-shop.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { shopDefine } from './defines/query-shop-define.ts';
|
||||
|
||||
import { BaseQuery, DataOpts, Query } from '@kevisual/query/query';
|
||||
|
||||
export { shopDefine };
|
||||
|
||||
export class QueryShop<T extends Query = Query> extends BaseQuery<T, typeof shopDefine> {
|
||||
constructor(opts?: { query: T }) {
|
||||
super({
|
||||
query: opts?.query!,
|
||||
queryDefine: shopDefine,
|
||||
});
|
||||
}
|
||||
getInstall(data: any, opts?: DataOpts) {
|
||||
return this.queryDefine.queryChain('install').post(data, opts);
|
||||
}
|
||||
}
|
134
src/query/query-upload/core/upload-chunk.ts
Normal file
134
src/query/query-upload/core/upload-chunk.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { randomId } from '../utils/random-id.ts';
|
||||
import { UploadProgress } from './upload-progress.ts';
|
||||
export type ConvertOpts = {
|
||||
appKey?: string;
|
||||
version?: string;
|
||||
username?: string;
|
||||
directory?: string;
|
||||
isPublic?: boolean;
|
||||
filename?: string;
|
||||
/**
|
||||
* 是否不检查应用文件, 默认 true,默认不检测
|
||||
*/
|
||||
noCheckAppFiles?: boolean;
|
||||
};
|
||||
|
||||
// createEventSource: (baseUrl: string, searchParams: URLSearchParams) => {
|
||||
// return new EventSource(baseUrl + '/api/s1/events?' + searchParams.toString());
|
||||
// },
|
||||
export type UploadOpts = {
|
||||
uploadProgress: UploadProgress;
|
||||
/**
|
||||
* 创建 EventSource 兼容 nodejs
|
||||
* @param baseUrl 基础 URL
|
||||
* @param searchParams 查询参数
|
||||
* @returns EventSource
|
||||
*/
|
||||
createEventSource: (baseUrl: string, searchParams: URLSearchParams) => EventSource;
|
||||
baseUrl?: string;
|
||||
token: string;
|
||||
FormDataFn: any;
|
||||
};
|
||||
export const uploadFileChunked = async (file: File, opts: ConvertOpts, opts2: UploadOpts) => {
|
||||
const { directory, appKey, version, username, isPublic, noCheckAppFiles = true } = opts;
|
||||
const { uploadProgress, createEventSource, baseUrl = '', token, FormDataFn } = opts2 || {};
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const taskId = randomId();
|
||||
const filename = opts.filename || file.name;
|
||||
uploadProgress?.start(`${filename} 上传中...`);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('taskId', taskId);
|
||||
if (isPublic) {
|
||||
searchParams.set('public', 'true');
|
||||
}
|
||||
if (noCheckAppFiles) {
|
||||
searchParams.set('noCheckAppFiles', '1');
|
||||
}
|
||||
const eventSource = createEventSource(baseUrl + '/api/s1/events', searchParams);
|
||||
let isError = false;
|
||||
// 监听服务器推送的进度更新
|
||||
eventSource.onmessage = function (event) {
|
||||
console.log('Progress update:', event.data);
|
||||
const parseIfJson = (data: string) => {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
const receivedData = parseIfJson(event.data);
|
||||
if (typeof receivedData === 'string') return;
|
||||
const progress = Number(receivedData.progress);
|
||||
const progressFixed = progress.toFixed(2);
|
||||
uploadProgress?.set(progress, { ...receivedData, progressFixed, filename, taskId });
|
||||
};
|
||||
eventSource.onerror = function (event) {
|
||||
console.log('eventSource.onerror', event);
|
||||
isError = true;
|
||||
reject(event);
|
||||
};
|
||||
|
||||
const chunkSize = 1 * 1024 * 1024; // 1MB
|
||||
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
for (let currentChunk = 0; currentChunk < totalChunks; currentChunk++) {
|
||||
const start = currentChunk * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const formData = new FormDataFn();
|
||||
formData.append('file', chunk, filename);
|
||||
formData.append('chunkIndex', currentChunk.toString());
|
||||
formData.append('totalChunks', totalChunks.toString());
|
||||
const isLast = currentChunk === totalChunks - 1;
|
||||
if (directory) {
|
||||
formData.append('directory', directory);
|
||||
}
|
||||
if (appKey && version) {
|
||||
formData.append('appKey', appKey);
|
||||
formData.append('version', version);
|
||||
}
|
||||
if (username) {
|
||||
formData.append('username', username);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(baseUrl + '/api/s1/resources/upload/chunk?taskId=' + taskId, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'task-id': taskId,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
|
||||
if (res?.code !== 200) {
|
||||
console.log('uploadChunk error', res);
|
||||
uploadProgress?.error(res?.message || '上传失败');
|
||||
isError = true;
|
||||
eventSource.close();
|
||||
|
||||
uploadProgress?.done();
|
||||
reject(new Error(res?.message || '上传失败'));
|
||||
return;
|
||||
}
|
||||
if (isLast) {
|
||||
fetch(baseUrl + '/api/s1/events/close?taskId=' + taskId);
|
||||
eventSource.close();
|
||||
uploadProgress?.done();
|
||||
resolve(res);
|
||||
}
|
||||
// console.log(`Chunk ${currentChunk + 1}/${totalChunks} uploaded`, res);
|
||||
} catch (error) {
|
||||
console.log('Error uploading chunk', error);
|
||||
fetch(baseUrl + '/api/s1/events/close?taskId=' + taskId);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 循环结束
|
||||
if (!uploadProgress?.end) {
|
||||
uploadProgress?.done();
|
||||
}
|
||||
});
|
||||
};
|
103
src/query/query-upload/core/upload-progress.ts
Normal file
103
src/query/query-upload/core/upload-progress.ts
Normal file
@ -0,0 +1,103 @@
|
||||
interface UploadNProgress {
|
||||
start: (msg?: string) => void;
|
||||
done: () => void;
|
||||
set: (progress: number) => void;
|
||||
}
|
||||
export type UploadProgressData = {
|
||||
progress: number;
|
||||
progressFixed: number;
|
||||
filename?: string;
|
||||
taskId?: string;
|
||||
};
|
||||
type UploadProgressOpts = {
|
||||
onStart?: () => void;
|
||||
onDone?: () => void;
|
||||
onProgress?: (progress: number, data?: UploadProgressData) => void;
|
||||
};
|
||||
export class UploadProgress implements UploadNProgress {
|
||||
/**
|
||||
* 进度
|
||||
*/
|
||||
progress: number;
|
||||
/**
|
||||
* 开始回调
|
||||
*/
|
||||
onStart: (() => void) | undefined;
|
||||
/**
|
||||
* 结束回调
|
||||
*/
|
||||
onDone: (() => void) | undefined;
|
||||
/**
|
||||
* 消息回调
|
||||
*/
|
||||
onProgress: ((progress: number, data?: UploadProgressData) => void) | undefined;
|
||||
/**
|
||||
* 数据
|
||||
*/
|
||||
data: any;
|
||||
/**
|
||||
* 是否结束
|
||||
*/
|
||||
end: boolean;
|
||||
constructor(uploadOpts: UploadProgressOpts) {
|
||||
this.progress = 0;
|
||||
this.end = false;
|
||||
const mockFn = () => {};
|
||||
this.onStart = uploadOpts.onStart || mockFn;
|
||||
this.onDone = uploadOpts.onDone || mockFn;
|
||||
this.onProgress = uploadOpts.onProgress || mockFn;
|
||||
}
|
||||
start(msg?: string) {
|
||||
this.progress = 0;
|
||||
msg && this.info(msg);
|
||||
this.end = false;
|
||||
this.onStart?.();
|
||||
}
|
||||
done() {
|
||||
this.progress = 100;
|
||||
this.end = true;
|
||||
this.onDone?.();
|
||||
}
|
||||
set(progress: number, data?: UploadProgressData) {
|
||||
this.progress = progress;
|
||||
this.data = data;
|
||||
this.onProgress?.(progress, data);
|
||||
console.log('uploadProgress set', progress, data);
|
||||
}
|
||||
/**
|
||||
* 开始回调
|
||||
*/
|
||||
setOnStart(callback: () => void) {
|
||||
this.onStart = callback;
|
||||
}
|
||||
/**
|
||||
* 结束回调
|
||||
*/
|
||||
setOnDone(callback: () => void) {
|
||||
this.onDone = callback;
|
||||
}
|
||||
/**
|
||||
* 消息回调
|
||||
*/
|
||||
setOnProgress(callback: (progress: number, data?: UploadProgressData) => void) {
|
||||
this.onProgress = callback;
|
||||
}
|
||||
/**
|
||||
* 打印信息
|
||||
*/
|
||||
info(msg: string) {
|
||||
console.log(msg);
|
||||
}
|
||||
/**
|
||||
* 打印错误
|
||||
*/
|
||||
error(msg: string) {
|
||||
console.error(msg);
|
||||
}
|
||||
/**
|
||||
* 打印警告
|
||||
*/
|
||||
warn(msg: string) {
|
||||
console.warn(msg);
|
||||
}
|
||||
}
|
113
src/query/query-upload/core/upload.ts
Normal file
113
src/query/query-upload/core/upload.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { randomId } from '../utils/random-id.ts';
|
||||
import type { UploadOpts } from './upload-chunk.ts';
|
||||
type ConvertOpts = {
|
||||
appKey?: string;
|
||||
version?: string;
|
||||
username?: string;
|
||||
directory?: string;
|
||||
/**
|
||||
* 文件大小限制
|
||||
*/
|
||||
maxSize?: number;
|
||||
/**
|
||||
* 文件数量限制
|
||||
*/
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 是否不检查应用文件, 默认 true,默认不检测
|
||||
*/
|
||||
noCheckAppFiles?: boolean;
|
||||
};
|
||||
|
||||
export const uploadFiles = async (files: File[], opts: ConvertOpts, opts2: UploadOpts) => {
|
||||
const { directory, appKey, version, username, noCheckAppFiles = true } = opts;
|
||||
const { uploadProgress, createEventSource, baseUrl = '', token, FormDataFn } = opts2 || {};
|
||||
const length = files.length;
|
||||
const maxSize = opts.maxSize || 20 * 1024 * 1024; // 20MB
|
||||
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
|
||||
if (totalSize > maxSize) {
|
||||
const maxSizeMB = maxSize / 1024 / 1024;
|
||||
uploadProgress?.error('有文件大小不能超过' + maxSizeMB + 'MB');
|
||||
return;
|
||||
}
|
||||
const maxCount = opts.maxCount || 10;
|
||||
if (length > maxCount) {
|
||||
uploadProgress?.error(`最多只能上传${maxCount}个文件`);
|
||||
return;
|
||||
}
|
||||
uploadProgress?.info(`上传中,共${length}个文件`);
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormDataFn();
|
||||
const webkitRelativePath = files[0]?.webkitRelativePath;
|
||||
const keepDirectory = webkitRelativePath !== '';
|
||||
const root = keepDirectory ? webkitRelativePath.split('/')[0] : '';
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (keepDirectory) {
|
||||
// relativePath 去除第一级
|
||||
const webkitRelativePath = file.webkitRelativePath.replace(root + '/', '');
|
||||
formData.append('file', file, webkitRelativePath); // 保留文件夹路径
|
||||
} else {
|
||||
formData.append('file', files[i], files[i].name);
|
||||
}
|
||||
}
|
||||
if (directory) {
|
||||
formData.append('directory', directory);
|
||||
}
|
||||
if (appKey && version) {
|
||||
formData.append('appKey', appKey);
|
||||
formData.append('version', version);
|
||||
}
|
||||
if (username) {
|
||||
formData.append('username', username);
|
||||
}
|
||||
const searchParams = new URLSearchParams();
|
||||
const taskId = randomId();
|
||||
searchParams.set('taskId', taskId);
|
||||
|
||||
if (noCheckAppFiles) {
|
||||
searchParams.set('noCheckAppFiles', '1');
|
||||
}
|
||||
const eventSource = new EventSource('/api/s1/events?taskId=' + taskId);
|
||||
|
||||
uploadProgress?.start('上传中...');
|
||||
eventSource.onopen = async function (event) {
|
||||
const res = await fetch('/api/s1/resources/upload?' + searchParams.toString(), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'task-id': taskId,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
|
||||
console.log('upload success', res);
|
||||
fetch('/api/s1/events/close?taskId=' + taskId);
|
||||
eventSource.close();
|
||||
uploadProgress?.done();
|
||||
resolve(res);
|
||||
};
|
||||
// 监听服务器推送的进度更新
|
||||
eventSource.onmessage = function (event) {
|
||||
console.log('Progress update:', event.data);
|
||||
const parseIfJson = (data: string) => {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
const receivedData = parseIfJson(event.data);
|
||||
if (typeof receivedData === 'string') return;
|
||||
const progress = Number(receivedData.progress);
|
||||
const progressFixed = progress.toFixed(2);
|
||||
console.log('progress', progress);
|
||||
uploadProgress?.set(progress, { ...receivedData, taskId, progressFixed });
|
||||
};
|
||||
|
||||
eventSource.onerror = function (event) {
|
||||
console.log('eventSource.onerror', event);
|
||||
reject(event);
|
||||
};
|
||||
});
|
||||
};
|
51
src/query/query-upload/query-upload-browser.ts
Normal file
51
src/query/query-upload/query-upload-browser.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { UploadProgress, UploadProgressData } from './core/upload-progress.ts';
|
||||
import { uploadFileChunked } from './core/upload-chunk.ts';
|
||||
import { toFile, uploadFiles, randomId } from './query-upload.ts';
|
||||
|
||||
export { toFile, randomId };
|
||||
export { uploadFiles, uploadFileChunked, UploadProgress };
|
||||
|
||||
type UploadFileProps = {
|
||||
onStart?: () => void;
|
||||
onDone?: () => void;
|
||||
onProgress?: (progress: number, data: UploadProgressData) => void;
|
||||
onSuccess?: (res: any) => void;
|
||||
onError?: (err: any) => void;
|
||||
token?: string;
|
||||
};
|
||||
export type ConvertOpts = {
|
||||
appKey?: string;
|
||||
version?: string;
|
||||
username?: string;
|
||||
directory?: string;
|
||||
isPublic?: boolean;
|
||||
filename?: string;
|
||||
/**
|
||||
* 是否不检查应用文件, 默认 true,默认不检测
|
||||
*/
|
||||
noCheckAppFiles?: boolean;
|
||||
};
|
||||
|
||||
export const uploadChunk = async (file: File, opts: ConvertOpts, props?: UploadFileProps) => {
|
||||
const uploadProgress = new UploadProgress({
|
||||
onStart: function () {
|
||||
props?.onStart?.();
|
||||
},
|
||||
onDone: () => {
|
||||
props?.onDone?.();
|
||||
},
|
||||
onProgress: (progress, data) => {
|
||||
props?.onProgress?.(progress, data!);
|
||||
},
|
||||
});
|
||||
const result = await uploadFileChunked(file, opts, {
|
||||
uploadProgress,
|
||||
token: props?.token!,
|
||||
createEventSource: (url: string, searchParams: URLSearchParams) => {
|
||||
return new EventSource(url + '?' + searchParams.toString());
|
||||
},
|
||||
FormDataFn: FormData,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
0
src/query/query-upload/query-upload-node.ts
Normal file
0
src/query/query-upload/query-upload-node.ts
Normal file
11
src/query/query-upload/query-upload.ts
Normal file
11
src/query/query-upload/query-upload.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { uploadFiles } from './core/upload.ts';
|
||||
|
||||
import { uploadFileChunked } from './core/upload-chunk.ts';
|
||||
import { UploadProgress } from './core/upload-progress.ts';
|
||||
|
||||
export { uploadFiles, uploadFileChunked, UploadProgress };
|
||||
|
||||
export * from './utils/to-file.ts';
|
||||
export { randomId } from './utils/random-id.ts';
|
||||
|
||||
export { filterFiles } from './utils/filter-files.ts';
|
23
src/query/query-upload/utils/filter-files.ts
Normal file
23
src/query/query-upload/utils/filter-files.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 过滤文件, 过滤 .DS_Store, node_modules, 以.开头的文件, 过滤 __开头的文件
|
||||
* @param files
|
||||
* @returns
|
||||
*/
|
||||
export const filterFiles = (files: File[]) => {
|
||||
files = files.filter((file) => {
|
||||
if (file.webkitRelativePath.startsWith('__MACOSX')) {
|
||||
return false;
|
||||
}
|
||||
// 过滤node_modules
|
||||
if (file.webkitRelativePath.includes('node_modules')) {
|
||||
return false;
|
||||
}
|
||||
// 过滤文件 .DS_Store
|
||||
if (file.name === '.DS_Store') {
|
||||
return false;
|
||||
}
|
||||
// 过滤以.开头的文件
|
||||
return !file.name.startsWith('.');
|
||||
});
|
||||
return files;
|
||||
};
|
3
src/query/query-upload/utils/index.ts
Normal file
3
src/query/query-upload/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './to-file.ts';
|
||||
export * from './filter-files.ts';
|
||||
export * from './random-id.ts';
|
3
src/query/query-upload/utils/random-id.ts
Normal file
3
src/query/query-upload/utils/random-id.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const randomId = () => {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
};
|
105
src/query/query-upload/utils/to-file.ts
Normal file
105
src/query/query-upload/utils/to-file.ts
Normal file
@ -0,0 +1,105 @@
|
||||
const getFileExtension = (filename: string) => {
|
||||
return filename.split('.').pop();
|
||||
};
|
||||
const getFileType = (extension: string) => {
|
||||
switch (extension) {
|
||||
case 'js':
|
||||
return 'text/javascript';
|
||||
case 'css':
|
||||
return 'text/css';
|
||||
case 'html':
|
||||
return 'text/html';
|
||||
case 'json':
|
||||
return 'application/json';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'jpg':
|
||||
return 'image/jpeg';
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'svg':
|
||||
return 'image/svg+xml';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'ico':
|
||||
return 'image/x-icon';
|
||||
default:
|
||||
return 'text/plain';
|
||||
}
|
||||
};
|
||||
const checkIsBase64 = (content: string) => {
|
||||
return content.startsWith('data:');
|
||||
};
|
||||
/**
|
||||
* 获取文件的目录和文件名
|
||||
* @param filename 文件名
|
||||
* @returns 目录和文件名
|
||||
*/
|
||||
export const getDirectoryAndName = (filename: string) => {
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
if (filename.startsWith('.')) {
|
||||
return null;
|
||||
} else {
|
||||
filename = filename.replace(/^\/+/, ''); // Remove all leading slashes
|
||||
}
|
||||
const hasDirectory = filename.includes('/');
|
||||
if (!hasDirectory) {
|
||||
return { directory: '', name: filename };
|
||||
}
|
||||
const parts = filename.split('/');
|
||||
const name = parts.pop()!; // Get the last part as the file name
|
||||
const directory = parts.join('/'); // Join the remaining parts as the directory
|
||||
return { directory, name };
|
||||
};
|
||||
/**
|
||||
* 把字符串转为文件流,并返回文件流,根据filename的扩展名,自动设置文件类型.
|
||||
* 当不是文本类型,自动需要把base64的字符串转为blob
|
||||
* @param content 字符串
|
||||
* @param filename 文件名
|
||||
* @returns 文件流
|
||||
*/
|
||||
export const toFile = (content: string, filename: string) => {
|
||||
// 如果文件名是 a/d/a.js 格式的,则需要把d作为目录,a.js作为文件名
|
||||
const directoryAndName = getDirectoryAndName(filename);
|
||||
if (!directoryAndName) {
|
||||
throw new Error('Invalid filename');
|
||||
}
|
||||
const { name } = directoryAndName;
|
||||
const extension = getFileExtension(name);
|
||||
if (!extension) {
|
||||
throw new Error('Invalid filename');
|
||||
}
|
||||
const isBase64 = checkIsBase64(content);
|
||||
const type = getFileType(extension);
|
||||
|
||||
if (isBase64) {
|
||||
// Decode base64 string
|
||||
const base64Data = content.split(',')[1]; // Remove the data URL prefix
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type });
|
||||
return new File([blob], filename, { type });
|
||||
} else {
|
||||
const blob = new Blob([content], { type });
|
||||
return new File([blob], filename, { type });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 把字符串转为文本文件
|
||||
* @param content 字符串
|
||||
* @param filename 文件名
|
||||
* @returns 文件流
|
||||
*/
|
||||
export const toTextFile = (content: string = 'keep directory exist', filename: string = 'keep.txt') => {
|
||||
const file = toFile(content, filename);
|
||||
return file;
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user