add ai-chat

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

3
.gitmodules vendored
View File

@ -0,0 +1,3 @@
[submodule "packages/markdown-editor"]
path = packages/markdown-editor
url = git@git.xiongxiao.me:kevisual/markdown-editor.git

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,153 @@
import { query } from '@/modules/query';
import { Query } from '@kevisual/query';
import { DataOpts } from '@kevisual/query/query';
export class QueryChat {
query: Query;
constructor({ query }: { query: Query }) {
this.query = query;
}
getChatList(opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'get-chat-list',
},
opts,
);
}
getChat(id: string, opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'get-chat',
data: {
id,
},
},
opts,
);
}
updateChat(data: any, opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'update-chat',
data,
},
opts,
);
}
deleteChat(id: string, opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'delete-chat',
data: {
id,
},
},
opts,
);
}
/**
*
* @param opts
* @returns
*/
getModelList(data?: { usernames?: string[] }, opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'get-model-list',
data,
},
opts,
);
}
/**
*
* @param data
* @param chatOpts
* @param opts
* @returns
*/
chat(data: ChatDataOpts, chatOpts: ChatOpts, opts?: DataOpts) {
const { username, model, group, getFull = true } = chatOpts;
if (!username || !model || !group) {
throw new Error('username, model, group is required');
}
return this.query.post(
{
path: 'ai',
key: 'chat',
...chatOpts,
getFull,
data,
},
opts,
);
}
clearConfigCache(opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'clear-cache',
},
opts,
);
}
/**
* 使
* @param opts
* @returns
*/
getChatUsage(opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'get-chat-usage',
},
opts,
);
}
/**
*
* @param opts
* @returns
*/
clearSelfUsage(opts?: DataOpts) {
return this.query.post(
{
path: 'ai',
key: 'clear-chat-limit',
},
opts,
);
}
}
export type ChatDataOpts = {
id?: string;
title?: string;
messages?: any[];
data?: any;
type?: 'temp' | 'keep' | string;
};
export type ChatOpts = {
username: string;
model: string;
/**
*
*/
getFull?: boolean;
group: string;
/**
* openai的参数
*/
options?: any;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)} />;
};

View File

@ -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>

View 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} />;
};

View 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;
};

View 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} />;
};

View File

@ -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 || [];

View File

@ -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>

View File

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

View File

@ -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 />

View File

@ -1,4 +1,4 @@
# srvice
# service
关于服务器构思分享。

View File

@ -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();

View File

@ -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
View 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": {}
}

View 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 服务,生成结果,并返回。',
},
});

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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'),
});
}
}

View 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(),
});
}
}

View 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;
}
/**
* onSuccessonError
* @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;
},
},
);
}
/**
* 401token, 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 401token, 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 };
}
}

View 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);
}
}

View 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 参数',
},
});

View 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);
}
}

View 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();
}
});
};

View 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);
}
}

View 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);
};
});
};

View 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;
};

View 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';

View 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;
};

View File

@ -0,0 +1,3 @@
export * from './to-file.ts';
export * from './filter-files.ts';
export * from './random-id.ts';

View File

@ -0,0 +1,3 @@
export const randomId = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};

View 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;
};