generated from template/astro-template
	add ai-chat
This commit is contained in:
		
							
								
								
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
[submodule "packages/markdown-editor"]
 | 
			
		||||
	path = packages/markdown-editor
 | 
			
		||||
	url = git@git.xiongxiao.me:kevisual/markdown-editor.git
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								package.json
									
									
									
									
									
								
							@@ -1,14 +1,14 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@kevisual/astro-template",
 | 
			
		||||
  "name": "@kevisual/ai-pages",
 | 
			
		||||
  "version": "0.0.1",
 | 
			
		||||
  "description": "",
 | 
			
		||||
  "main": "index.js",
 | 
			
		||||
  "basename": "/root/astro-template",
 | 
			
		||||
  "basename": "/root/ai-pages",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "astro dev",
 | 
			
		||||
    "build": "astro build",
 | 
			
		||||
    "preview": "astro preview",
 | 
			
		||||
    "pub": "envision deploy ./dist -k astro-template -v 0.0.1 -u",
 | 
			
		||||
    "pub": "ev deploy ./dist -k ai-pages -v 0.0.1 -u",
 | 
			
		||||
    "git:submodule": "git submodule update --init --recursive",
 | 
			
		||||
    "sn": "pnpm dlx shadcn@latest add "
 | 
			
		||||
  },
 | 
			
		||||
@@ -17,11 +17,11 @@
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@astrojs/mdx": "^4.2.6",
 | 
			
		||||
    "@astrojs/react": "^4.2.7",
 | 
			
		||||
    "@astrojs/mdx": "^4.3.0",
 | 
			
		||||
    "@astrojs/react": "^4.3.0",
 | 
			
		||||
    "@astrojs/sitemap": "^3.4.0",
 | 
			
		||||
    "@kevisual/query": "^0.0.18",
 | 
			
		||||
    "@kevisual/query-login": "^0.0.5",
 | 
			
		||||
    "@kevisual/cache": "^0.0.3",
 | 
			
		||||
    "@kevisual/query-login": "^0.0.6",
 | 
			
		||||
    "@kevisual/registry": "^0.0.1",
 | 
			
		||||
    "@radix-ui/react-alert-dialog": "^1.1.14",
 | 
			
		||||
    "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
@@ -31,30 +31,47 @@
 | 
			
		||||
    "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
    "@radix-ui/react-tooltip": "^1.2.7",
 | 
			
		||||
    "@tailwindcss/vite": "^4.1.7",
 | 
			
		||||
    "astro": "^5.7.13",
 | 
			
		||||
    "astro": "^5.8.0",
 | 
			
		||||
    "class-variance-authority": "^0.7.1",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "cmdk": "^1.1.1",
 | 
			
		||||
    "dayjs": "^1.11.13",
 | 
			
		||||
    "highlight.js": "^11.11.1",
 | 
			
		||||
    "i18next": "^25.2.0",
 | 
			
		||||
    "i18next-browser-languagedetector": "^8.1.0",
 | 
			
		||||
    "i18next-http-backend": "^3.0.2",
 | 
			
		||||
    "lodash-es": "^4.17.21",
 | 
			
		||||
    "lucide-react": "^0.511.0",
 | 
			
		||||
    "marked": "^15.0.12",
 | 
			
		||||
    "marked-highlight": "^2.2.1",
 | 
			
		||||
    "nanoid": "^5.1.5",
 | 
			
		||||
    "pretty-bytes": "^7.0.0",
 | 
			
		||||
    "re-resizable": "^6.11.2",
 | 
			
		||||
    "react": "^19.1.0",
 | 
			
		||||
    "react-dom": "^19.1.0",
 | 
			
		||||
    "react-draggable": "^4.4.6",
 | 
			
		||||
    "react-hook-form": "^7.56.4",
 | 
			
		||||
    "react-i18next": "^15.5.2",
 | 
			
		||||
    "react-sortablejs": "^6.1.4",
 | 
			
		||||
    "react-toastify": "^11.0.5",
 | 
			
		||||
    "sortablejs": "^1.15.6",
 | 
			
		||||
    "tailwind-merge": "^3.3.0",
 | 
			
		||||
    "zustand": "^5.0.4"
 | 
			
		||||
    "zustand": "^5.0.5"
 | 
			
		||||
  },
 | 
			
		||||
  "publishConfig": {
 | 
			
		||||
    "access": "public"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@kevisual/markdown-editor": "workspace:*",
 | 
			
		||||
    "@kevisual/query": "^0.0.20",
 | 
			
		||||
    "@kevisual/query-awesome": "^0.0.2",
 | 
			
		||||
    "@kevisual/router": "^0.0.21",
 | 
			
		||||
    "@kevisual/store": "^0.0.8",
 | 
			
		||||
    "@kevisual/types": "^0.0.10",
 | 
			
		||||
    "@types/react": "^19.1.4",
 | 
			
		||||
    "@types/lodash-es": "^4.17.12",
 | 
			
		||||
    "@types/react": "^19.1.5",
 | 
			
		||||
    "@types/react-dom": "^19.1.5",
 | 
			
		||||
    "@types/sortablejs": "^1.15.8",
 | 
			
		||||
    "@vitejs/plugin-basic-ssl": "^2.0.0",
 | 
			
		||||
    "commander": "^14.0.0",
 | 
			
		||||
    "dotenv": "^16.5.0",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1619
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1619
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										124
									
								
								src/apps/ai-chat/chat-context/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/apps/ai-chat/chat-context/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,124 @@
 | 
			
		||||
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { Button } from '@/components/a/button.tsx';
 | 
			
		||||
import { TextEditor } from '@kevisual/markdown-editor/tiptap/editor.ts';
 | 
			
		||||
import { Select } from '@/components/a/select.tsx';
 | 
			
		||||
import { useEffect, useRef, useState } from 'react';
 | 
			
		||||
import { getSuggestionItems } from '../editor/suggestion/item';
 | 
			
		||||
import { html2md } from '@kevisual/markdown-editor/tiptap/index.ts';
 | 
			
		||||
import { chatId } from '../utils/uuid';
 | 
			
		||||
 | 
			
		||||
export const ChatContextDialog = () => {
 | 
			
		||||
  const computedHeight = getComputedHeight();
 | 
			
		||||
  const { showContext, setShowContext, updateMessage, contextMessage, setContextMessage, setModelId, modelId } = useChatStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      showContext: state.showContext,
 | 
			
		||||
      setShowContext: state.setShowContext,
 | 
			
		||||
      updateMessage: state.updateMessage,
 | 
			
		||||
      contextMessage: state.contextMessage,
 | 
			
		||||
      setContextMessage: state.setContextMessage,
 | 
			
		||||
      setModelId: state.setModelId,
 | 
			
		||||
      modelId: state.modelId,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const editorRef = useRef<TextEditor | null>(null);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (ref.current) {
 | 
			
		||||
      editorRef.current = new TextEditor();
 | 
			
		||||
      editorRef.current.createEditor(ref.current, {
 | 
			
		||||
        items: getSuggestionItems(),
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    return () => {
 | 
			
		||||
      if (ref.current && editorRef.current) {
 | 
			
		||||
        editorRef.current?.destroy?.();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (showContext && contextMessage?.content) {
 | 
			
		||||
      contentToEditor(contextMessage.content);
 | 
			
		||||
      contextMessage?.role && setRole(contextMessage.role);
 | 
			
		||||
    } else if (showContext && typeof contextMessage === 'undefined') {
 | 
			
		||||
      editorRef.current?.setContent('');
 | 
			
		||||
    }
 | 
			
		||||
  }, [showContext]);
 | 
			
		||||
  const contentToEditor = async (content: string) => {
 | 
			
		||||
    editorRef.current?.setContent(content);
 | 
			
		||||
  };
 | 
			
		||||
  const onSave = async () => {
 | 
			
		||||
    const hasId = contextMessage?.id;
 | 
			
		||||
    const html = editorRef.current?.getHtml()!;
 | 
			
		||||
    const md = await html2md(html);
 | 
			
		||||
    const newMessage = {
 | 
			
		||||
      ...contextMessage,
 | 
			
		||||
      role: role,
 | 
			
		||||
      id: hasId || chatId(),
 | 
			
		||||
      content: md,
 | 
			
		||||
      name: 'user',
 | 
			
		||||
    };
 | 
			
		||||
    if (!hasId) {
 | 
			
		||||
      newMessage.createdAt = new Date().getTime();
 | 
			
		||||
    }
 | 
			
		||||
    newMessage.updatedAt = new Date().getTime();
 | 
			
		||||
    updateMessage(newMessage);
 | 
			
		||||
    setShowContext(false);
 | 
			
		||||
  };
 | 
			
		||||
  const [role, setRole] = useState<string>('user');
 | 
			
		||||
  return (
 | 
			
		||||
    <DragModal
 | 
			
		||||
      focus={modelId === 'chat-context'}
 | 
			
		||||
      title={
 | 
			
		||||
        <DragModalTitle
 | 
			
		||||
          title='Markdown编辑器'
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            setModelId('chat-context');
 | 
			
		||||
          }}
 | 
			
		||||
          onClose={() => {
 | 
			
		||||
            setShowContext(false);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      containerClassName={showContext ? '' : 'hidden'}
 | 
			
		||||
      content={
 | 
			
		||||
        <div className='w-full h-full p-2 '>
 | 
			
		||||
          <div className='text-sm text-gray-500 border-b border-gray-200 pb-2 flex items-center gap-2'>
 | 
			
		||||
            角色:
 | 
			
		||||
            <Select
 | 
			
		||||
              className='w-40 ml-2'
 | 
			
		||||
              size='small'
 | 
			
		||||
              value={role}
 | 
			
		||||
              onChange={(e) => {
 | 
			
		||||
                setRole(e);
 | 
			
		||||
              }}
 | 
			
		||||
              options={[
 | 
			
		||||
                { label: 'User', value: 'user' },
 | 
			
		||||
                { label: 'Assistant', value: 'assistant' },
 | 
			
		||||
                { label: 'System', value: 'system' },
 | 
			
		||||
                { label: 'Tools', value: 'tool' },
 | 
			
		||||
              ]}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className='w-full scrollbar' style={{ height: 'calc(100% - 90px)' }} ref={ref}></div>
 | 
			
		||||
          <div className='flex justify-end gap-2 ' style={{ height: '40px' }}>
 | 
			
		||||
            <Button color='primary' onClick={onSave}>
 | 
			
		||||
              保存
 | 
			
		||||
            </Button>
 | 
			
		||||
            <Button onClick={() => setShowContext(false)}>取消</Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      }
 | 
			
		||||
      defaultSize={{
 | 
			
		||||
        width: 600,
 | 
			
		||||
        height: 400,
 | 
			
		||||
      }}
 | 
			
		||||
      style={{
 | 
			
		||||
        left: computedHeight.width / 2 - 300,
 | 
			
		||||
        top: computedHeight.height / 2 - 200,
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										46
									
								
								src/apps/ai-chat/chat-copy/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/apps/ai-chat/chat-copy/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { ChatCopyList } from './List';
 | 
			
		||||
 | 
			
		||||
export const ChatCopyDialog = () => {
 | 
			
		||||
  const store = useChatStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      showCopy: state.showCopy,
 | 
			
		||||
      setShowCopy: state.setShowCopy,
 | 
			
		||||
      setModelId: state.setModelId,
 | 
			
		||||
      modelId: state.modelId,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
  const computedHeight = getComputedHeight();
 | 
			
		||||
  if (!store.showCopy) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <DragModal
 | 
			
		||||
      focus={store.modelId === 'chat-copy'}
 | 
			
		||||
      title={
 | 
			
		||||
        <DragModalTitle
 | 
			
		||||
          title='Chat Copy'
 | 
			
		||||
          onClose={() => store.setShowCopy(false)}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            store.setModelId('chat-copy');
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      content={
 | 
			
		||||
        <div className='w-full h-full p-2 overflow-y-auto scrollbar'>
 | 
			
		||||
          <ChatCopyList />
 | 
			
		||||
        </div>
 | 
			
		||||
      }
 | 
			
		||||
      defaultSize={{
 | 
			
		||||
        width: 600,
 | 
			
		||||
        height: 400,
 | 
			
		||||
      }}
 | 
			
		||||
      style={{
 | 
			
		||||
        left: computedHeight.width / 2 - 300,
 | 
			
		||||
        top: computedHeight.height / 2 - 200,
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										151
									
								
								src/apps/ai-chat/chat-copy/List.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								src/apps/ai-chat/chat-copy/List.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,151 @@
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { IconButton } from '@/components/a/button.tsx'
 | 
			
		||||
import { Bug, BugOff, Copy, Download, RotateCcw, Upload, X } from 'lucide-react';
 | 
			
		||||
import { Divider } from '@/components/a/divider.tsx'
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip.tsx'
 | 
			
		||||
import { copyText } from '../utils/copy';
 | 
			
		||||
import { useEffect, useRef, useState } from 'react';
 | 
			
		||||
import { getCodeTemplate, runCode } from '../store/generte-text';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
 | 
			
		||||
export type SelfUsage = {
 | 
			
		||||
  group: string;
 | 
			
		||||
  model: string;
 | 
			
		||||
  token: number;
 | 
			
		||||
  [key: string]: any;
 | 
			
		||||
};
 | 
			
		||||
export type UsageData = {
 | 
			
		||||
  rootUsage?: {
 | 
			
		||||
    token?: number;
 | 
			
		||||
  };
 | 
			
		||||
  selfUsage?: SelfUsage[];
 | 
			
		||||
};
 | 
			
		||||
export const ChatCopyList = () => {
 | 
			
		||||
  const store = useChatStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      currentUserModel: state.currentUserModel,
 | 
			
		||||
      setCurrentUserModel: state.setCurrentUserModel,
 | 
			
		||||
      modelList: state.modelList,
 | 
			
		||||
      clearConfigCache: state.clearConfigCache,
 | 
			
		||||
      showSetting: state.showSetting,
 | 
			
		||||
      getModelList: state.getModelList,
 | 
			
		||||
      messages: state.messages,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
  const contentEditableRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const [text, setText] = useState('');
 | 
			
		||||
  const [mode, setMode] = useState<'edit' | 'copy'>('edit');
 | 
			
		||||
  const [code, setCode] = useState('');
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    generte();
 | 
			
		||||
  }, [store.messages, code]);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const code = localStorage.getItem('code-copy-template');
 | 
			
		||||
    const cacheMode = localStorage.getItem('code-copy-mode');
 | 
			
		||||
    if (code) {
 | 
			
		||||
      setCode(code);
 | 
			
		||||
    } else {
 | 
			
		||||
      setCode(getCodeTemplate());
 | 
			
		||||
    }
 | 
			
		||||
    if (cacheMode) {
 | 
			
		||||
      setMode(cacheMode as 'edit' | 'copy');
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
  const generte = async () => {
 | 
			
		||||
    if (!contentEditableRef.current) return;
 | 
			
		||||
    if (!code) return;
 | 
			
		||||
    try {
 | 
			
		||||
      const jsModule = await runCode(code);
 | 
			
		||||
      const result = await jsModule.generateText(store.messages);
 | 
			
		||||
      setText(result);
 | 
			
		||||
      contentEditableRef.current.innerHTML = result;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      contentEditableRef.current.innerHTML = 'error run code';
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  const setCodeTemplate = () => {
 | 
			
		||||
    const codeTemplate = getCodeTemplate();
 | 
			
		||||
    localStorage.setItem('code-copy-template', codeTemplate);
 | 
			
		||||
    setCode(codeTemplate);
 | 
			
		||||
  };
 | 
			
		||||
  const changeMode = () => {
 | 
			
		||||
    setMode(mode === 'edit' ? 'copy' : 'edit');
 | 
			
		||||
    localStorage.setItem('code-copy-mode', mode === 'edit' ? 'copy' : 'edit');
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full p-2'>
 | 
			
		||||
      <div className='pt-1 flex gap-2 pb-4'>
 | 
			
		||||
        <Tooltip title='复制'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              copyText(text);
 | 
			
		||||
            }}>
 | 
			
		||||
            <Copy className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='导出json'>
 | 
			
		||||
          <IconButton>
 | 
			
		||||
            <Download className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='导入json'>
 | 
			
		||||
          <IconButton>
 | 
			
		||||
            <Upload className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Divider orientation='vertical' />
 | 
			
		||||
        <Tooltip title={mode === 'edit' ? '编辑导出模式' : '预览模式'}>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              changeMode();
 | 
			
		||||
            }}>
 | 
			
		||||
            {mode === 'edit' ? <Bug className='w-4 h-4' /> : <BugOff className='w-4 h-4' />}
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='恢复默认的代码配置'>
 | 
			
		||||
          <IconButton onClick={() => setCodeTemplate()}>
 | 
			
		||||
            <RotateCcw className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Divider />
 | 
			
		||||
      <div
 | 
			
		||||
        className='flex gap-2 py-4 text-sm scrollbar overflow-y-auto'
 | 
			
		||||
        style={{
 | 
			
		||||
          height: 'calc(100% - 40px)',
 | 
			
		||||
        }}>
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx('w-full h-full whitespace-pre-wrap scrollbar', mode === 'edit' ? 'flex-1' : '')}
 | 
			
		||||
          contentEditable
 | 
			
		||||
          style={{
 | 
			
		||||
            outline: 'none',
 | 
			
		||||
            overflowY: 'auto',
 | 
			
		||||
          }}
 | 
			
		||||
          ref={contentEditableRef}></div>
 | 
			
		||||
        <div className={clsx('w-full h-full whitespace-pre-wrap', mode === 'edit' ? 'flex-1' : 'hidden')}>
 | 
			
		||||
          <Tooltip title='自动缓存到code-copy-template中'>
 | 
			
		||||
            <div className='text-sm w-[140px] flex gap-2 text-gray-500 pb-2 items-center'>
 | 
			
		||||
              编辑导出模版
 | 
			
		||||
              <div className='p-0.5 hover:bg-gray-100 rounded-md cursor-pointer' onClick={() => changeMode()}>
 | 
			
		||||
                <X className='w-4 h-4' />
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
          <textarea
 | 
			
		||||
            className='w-full border scrollbar border-gray-200 rounded-md shadow-sm p-4'
 | 
			
		||||
            value={code}
 | 
			
		||||
            onChange={(e) => {
 | 
			
		||||
              localStorage.setItem('code-copy-template', code);
 | 
			
		||||
              setCode(e.target.value);
 | 
			
		||||
            }}
 | 
			
		||||
            style={{
 | 
			
		||||
              height: 'calc(100% - 40px)',
 | 
			
		||||
              outline: 'none',
 | 
			
		||||
              overflowY: 'auto',
 | 
			
		||||
            }}></textarea>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										41
									
								
								src/apps/ai-chat/chat-history/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/apps/ai-chat/chat-history/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { ChatHistoryList } from './List';
 | 
			
		||||
 | 
			
		||||
export const ChatHistoryDialog = ({ storeId }: { storeId: string }) => {
 | 
			
		||||
  const { showList, setShowList, setModelId, modelId } = useChatStore(
 | 
			
		||||
    useShallow((state) => ({ showList: state.showList, setShowList: state.setShowList, setModelId: state.setModelId, modelId: state.modelId  })),
 | 
			
		||||
  );
 | 
			
		||||
  const computedHeight = getComputedHeight();
 | 
			
		||||
  if (!showList) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <DragModal
 | 
			
		||||
      focus={modelId === 'chat-history'}
 | 
			
		||||
      title={
 | 
			
		||||
        <DragModalTitle
 | 
			
		||||
          title='Chat History'
 | 
			
		||||
          onClose={() => setShowList(false)}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            setModelId('chat-history');
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      content={
 | 
			
		||||
        <div className='w-full h-full p-2 overflow-y-auto scrollbar'>
 | 
			
		||||
          <ChatHistoryList storeId={storeId} />
 | 
			
		||||
        </div>
 | 
			
		||||
      }
 | 
			
		||||
      defaultSize={{
 | 
			
		||||
        width: 600,
 | 
			
		||||
        height: 400,
 | 
			
		||||
      }}
 | 
			
		||||
      style={{
 | 
			
		||||
        left: computedHeight.width / 2 - 300,
 | 
			
		||||
        top: computedHeight.height / 2 - 200,
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										117
									
								
								src/apps/ai-chat/chat-history/List.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/apps/ai-chat/chat-history/List.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { IconButton, Button } from '@/components/a/button.tsx';
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip';
 | 
			
		||||
import { Edit, Plus, Trash, X } from 'lucide-react';
 | 
			
		||||
import { Input as TextField } from '@/components/a/input';
 | 
			
		||||
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { Controller, useForm } from 'react-hook-form';
 | 
			
		||||
import { Confirm } from '@/components/a/confirm';
 | 
			
		||||
 | 
			
		||||
export const ChatHistoryList = ({ storeId }: { storeId: string }) => {
 | 
			
		||||
  const { historyList, setId, updateChat, deleteChat, setShowList, newChat, setNewChat } = useChatStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      historyList: state.historyList,
 | 
			
		||||
      setId: state.setId,
 | 
			
		||||
      updateChat: state.updateChat,
 | 
			
		||||
      deleteChat: state.deleteChat,
 | 
			
		||||
      setShowList: state.setShowList,
 | 
			
		||||
      newChat: state.newChat,
 | 
			
		||||
      setNewChat: state.setNewChat,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
  const onChoose = (id: string) => {
 | 
			
		||||
    setId(id);
 | 
			
		||||
    setShowList(false);
 | 
			
		||||
    setHistoryState({
 | 
			
		||||
      [storeId]: {
 | 
			
		||||
        chatId: id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    const url = new URL(location.href);
 | 
			
		||||
    url.searchParams.set('chatId', id);
 | 
			
		||||
    setHistoryState({}, url.toString());
 | 
			
		||||
  };
 | 
			
		||||
  const { control, handleSubmit, reset, getValues } = useForm({ defaultValues: { title: '', id: '' } });
 | 
			
		||||
  const onFinish = async (data: any) => {
 | 
			
		||||
    const res = await updateChat(data);
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      setNewChat(false);
 | 
			
		||||
      reset({});
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  const id = getValues('id');
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full'>
 | 
			
		||||
      <div className='flex justify-end px-4 py-2 border-b border-gray-200'>
 | 
			
		||||
        <Tooltip title='New Context'>
 | 
			
		||||
          <IconButton onClick={() => setNewChat(!newChat)}>{newChat ? <X className='w-4 h-4' /> : <Plus className='w-4 h-4' />}</IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className='scrollbar pt-4' style={{ height: 'calc(100% - 60px)' }}>
 | 
			
		||||
        <div className={clsx('flex flex-col gap-2', newChat ? 'hidden' : '')}>
 | 
			
		||||
          {historyList.map((item) => {
 | 
			
		||||
            const chatState = getHistoryState()[storeId];
 | 
			
		||||
            const isCurrent = chatState?.chatId === item.id;
 | 
			
		||||
            return (
 | 
			
		||||
              <div key={item.id} className={clsx('flex justify-between items-center px-3 py-2', isCurrent ? 'bg-gray-100' : '')}>
 | 
			
		||||
                <div
 | 
			
		||||
                  className='min-w-[200px] truncate min-h-[20px] cursor-pointer'
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    onChoose(item.id);
 | 
			
		||||
                  }}>
 | 
			
		||||
                  {item.title}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className='flex gap-2'>
 | 
			
		||||
                  <Tooltip title='编辑标题'>
 | 
			
		||||
                    <IconButton
 | 
			
		||||
                      onClick={(e) => {
 | 
			
		||||
                        e.stopPropagation();
 | 
			
		||||
                        setNewChat(true);
 | 
			
		||||
                        console.log('setNewChat', item.id);
 | 
			
		||||
                        reset({
 | 
			
		||||
                          id: item.id,
 | 
			
		||||
                          title: item.title,
 | 
			
		||||
                        });
 | 
			
		||||
                      }}>
 | 
			
		||||
                      <Edit className='w-4 h-4' />
 | 
			
		||||
                    </IconButton>
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                  <Confirm
 | 
			
		||||
                    onOk={(e) => {
 | 
			
		||||
                      e.stopPropagation();
 | 
			
		||||
                      deleteChat(item.id);
 | 
			
		||||
                    }}>
 | 
			
		||||
                    <IconButton>
 | 
			
		||||
                      <Trash className='w-4 h-4' />
 | 
			
		||||
                    </IconButton>
 | 
			
		||||
                  </Confirm>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            );
 | 
			
		||||
          })}
 | 
			
		||||
          {historyList.length === 0 && <div className='text-center text-gray-500 py-2'>No chat history</div>}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className={clsx('flex flex-col gap-2 p-4', newChat ? '' : 'hidden')}>
 | 
			
		||||
          <form className='flex flex-col gap-2 ' onSubmit={handleSubmit(onFinish)}>
 | 
			
		||||
            <Controller control={control} name='title' render={({ field }) => <TextField {...field} placeholder='标题' label='标题' />} />
 | 
			
		||||
            <div className='flex gap-2'>
 | 
			
		||||
              <Button type='submit' color='primary'>
 | 
			
		||||
                {id ? '编辑' : '创建'}
 | 
			
		||||
              </Button>
 | 
			
		||||
              <Button
 | 
			
		||||
                type='reset'
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  setNewChat(false);
 | 
			
		||||
                  reset({});
 | 
			
		||||
                }}>
 | 
			
		||||
                取消
 | 
			
		||||
              </Button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										46
									
								
								src/apps/ai-chat/chat-model-setting/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/apps/ai-chat/chat-model-setting/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { ChatSettingList } from './List';
 | 
			
		||||
 | 
			
		||||
export const ChatModelSettingDialog = () => {
 | 
			
		||||
  const { showChatSetting, setShowChatSetting, setModelId, modelId } = useChatStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      showChatSetting: state.showChatSetting,
 | 
			
		||||
      setShowChatSetting: state.setShowChatSetting,
 | 
			
		||||
      setModelId: state.setModelId,
 | 
			
		||||
      modelId: state.modelId,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
  const computedHeight = getComputedHeight();
 | 
			
		||||
  if (!showChatSetting) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <DragModal
 | 
			
		||||
      focus={modelId === 'chat-model-setting'}
 | 
			
		||||
      title={
 | 
			
		||||
        <DragModalTitle
 | 
			
		||||
          title='Chat Models Setting'
 | 
			
		||||
          onClose={() => setShowChatSetting(false)}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            setModelId('chat-model-setting');
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      content={
 | 
			
		||||
        <div className='w-full h-full p-2 overflow-y-auto scrollbar'>
 | 
			
		||||
          <ChatSettingList />
 | 
			
		||||
        </div>
 | 
			
		||||
      }
 | 
			
		||||
      defaultSize={{
 | 
			
		||||
        width: 600,
 | 
			
		||||
        height: 400,
 | 
			
		||||
      }}
 | 
			
		||||
      style={{
 | 
			
		||||
        left: computedHeight.width / 2 - 300,
 | 
			
		||||
        top: computedHeight.height / 2 - 200,
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										67
									
								
								src/apps/ai-chat/chat-model-setting/List.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/apps/ai-chat/chat-model-setting/List.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { IconButton } from '@/components/a/button.tsx'
 | 
			
		||||
import { CircleCheckBig, Edit, LassoSelectIcon } from 'lucide-react';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip.tsx'
 | 
			
		||||
 | 
			
		||||
export const ChatSettingList = () => {
 | 
			
		||||
  const { currentUserModel, setCurrentUserModel, modelList, showChatSetting, setShowChatSetting } = useChatStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      currentUserModel: state.currentUserModel,
 | 
			
		||||
      setCurrentUserModel: state.setCurrentUserModel,
 | 
			
		||||
      modelList: state.modelList,
 | 
			
		||||
      showChatSetting: state.showChatSetting,
 | 
			
		||||
      setShowChatSetting: state.setShowChatSetting,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full p-2'>
 | 
			
		||||
      {modelList?.map?.((model, index) => {
 | 
			
		||||
        const selectOpts = model.selectOpts || ([] as { group: string; provider: string; model: string }[]);
 | 
			
		||||
        const isCurrentUserModel = currentUserModel?.username === model.username;
 | 
			
		||||
        const isLastModel = index === modelList.length - 1;
 | 
			
		||||
        return (
 | 
			
		||||
          <div key={index} className={clsx('border-b-gray-200 border-b pb-2', isLastModel ? 'border-b-0' : '')}>
 | 
			
		||||
            <div className='text-lg font-bold capitalize'>
 | 
			
		||||
              <Tooltip title={`使用用户: ${model.username} 的对话模型`}>
 | 
			
		||||
                <div className='cursor-pointer'>user: {model.username}</div>
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </div>
 | 
			
		||||
            {selectOpts.map((opt, index) => {
 | 
			
		||||
              const isLast = index === selectOpts.length - 1;
 | 
			
		||||
              const isSelected = isCurrentUserModel && currentUserModel?.model?.group === opt.group && currentUserModel?.model?.model === opt.model;
 | 
			
		||||
              return (
 | 
			
		||||
                <div
 | 
			
		||||
                  key={'select-opt-' + index}
 | 
			
		||||
                  className={clsx('border-b-gray-200 p-2 flex justify-between', isLast ? 'border-b-0' : 'border-b', isSelected ? 'bg-gray-100' : '')}>
 | 
			
		||||
                  <div>
 | 
			
		||||
                    <div>group: {opt.group}</div>
 | 
			
		||||
                    <div>provider: {opt.provider}</div>
 | 
			
		||||
                    <div>model: {opt.model}</div>
 | 
			
		||||
                    {opt.description && <div>{opt.description}</div>}
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div className=' gap-2'>
 | 
			
		||||
                    <Tooltip title='设置为当前模型'>
 | 
			
		||||
                      <IconButton
 | 
			
		||||
                        onClick={() => {
 | 
			
		||||
                          setCurrentUserModel({
 | 
			
		||||
                            username: model.username,
 | 
			
		||||
                            model: opt,
 | 
			
		||||
                          });
 | 
			
		||||
                          setShowChatSetting(false);
 | 
			
		||||
                        }}>
 | 
			
		||||
                        <CircleCheckBig className='w-4 h-4' />
 | 
			
		||||
                      </IconButton>
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              );
 | 
			
		||||
            })}
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
      })}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										46
									
								
								src/apps/ai-chat/chat-setting/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/apps/ai-chat/chat-setting/Dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
import { DragModal, DragModalTitle, getComputedHeight } from '@/components/a/drag-modal/index.tsx';
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { ChatSettingList } from './List';
 | 
			
		||||
 | 
			
		||||
export const ChatSettingDialog = () => {
 | 
			
		||||
  const { showSetting, setShowSetting, setModelId, modelId } = useChatStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      showSetting: state.showSetting,
 | 
			
		||||
      setShowSetting: state.setShowSetting,
 | 
			
		||||
      setModelId: state.setModelId,
 | 
			
		||||
      modelId: state.modelId,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
  const computedHeight = getComputedHeight();
 | 
			
		||||
  if (!showSetting) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <DragModal
 | 
			
		||||
      focus={modelId === 'chat-setting'}
 | 
			
		||||
      title={
 | 
			
		||||
        <DragModalTitle
 | 
			
		||||
          title='Chat Setting'
 | 
			
		||||
          onClose={() => setShowSetting(false)}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            setModelId('chat-setting');
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      content={
 | 
			
		||||
        <div className='w-full h-full p-2 overflow-y-auto scrollbar'>
 | 
			
		||||
          <ChatSettingList />
 | 
			
		||||
        </div>
 | 
			
		||||
      }
 | 
			
		||||
      defaultSize={{
 | 
			
		||||
        width: 600,
 | 
			
		||||
        height: 400,
 | 
			
		||||
      }}
 | 
			
		||||
      style={{
 | 
			
		||||
        left: computedHeight.width / 2 - 300,
 | 
			
		||||
        top: computedHeight.height / 2 - 200,
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										136
									
								
								src/apps/ai-chat/chat-setting/List.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/apps/ai-chat/chat-setting/List.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,136 @@
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { IconButton } from '@/components/a/button.tsx';
 | 
			
		||||
import { BookmarkX, CircleCheckBig, Copy, Edit, LassoSelectIcon, RefreshCcw, Trash } from 'lucide-react';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { Divider } from '@/components/a/divider.tsx'
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip.tsx'
 | 
			
		||||
import { useEffect, useState } from 'react';
 | 
			
		||||
import { queryChat } from '../store';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import prettyBytes from 'pretty-bytes';
 | 
			
		||||
import { copyText } from '../utils/copy';
 | 
			
		||||
export type SelfUsage = {
 | 
			
		||||
  group: string;
 | 
			
		||||
  model: string;
 | 
			
		||||
  token: number;
 | 
			
		||||
  [key: string]: any;
 | 
			
		||||
};
 | 
			
		||||
export type UsageData = {
 | 
			
		||||
  rootUsage?: {
 | 
			
		||||
    token?: number;
 | 
			
		||||
  };
 | 
			
		||||
  selfUsage?: SelfUsage[];
 | 
			
		||||
};
 | 
			
		||||
export const ChatSettingList = () => {
 | 
			
		||||
  const store = useChatStore(
 | 
			
		||||
    useShallow((state) => ({
 | 
			
		||||
      currentUserModel: state.currentUserModel,
 | 
			
		||||
      setCurrentUserModel: state.setCurrentUserModel,
 | 
			
		||||
      modelList: state.modelList,
 | 
			
		||||
      clearConfigCache: state.clearConfigCache,
 | 
			
		||||
      showSetting: state.showSetting,
 | 
			
		||||
      getModelList: state.getModelList,
 | 
			
		||||
      messages: state.messages,
 | 
			
		||||
    })),
 | 
			
		||||
  );
 | 
			
		||||
  const [usageData, setUsageData] = useState<UsageData>({});
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (store.showSetting) {
 | 
			
		||||
      getUsage();
 | 
			
		||||
    }
 | 
			
		||||
  }, [store.showSetting]);
 | 
			
		||||
  const getUsage = async () => {
 | 
			
		||||
    const res = await queryChat.getChatUsage();
 | 
			
		||||
    console.log(res);
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      setUsageData(res.data);
 | 
			
		||||
    } else {
 | 
			
		||||
      toast.error(res.message);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  const clearSelfUsage = async () => {
 | 
			
		||||
    const res = await queryChat.clearSelfUsage();
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      toast.success(res.message);
 | 
			
		||||
    } else {
 | 
			
		||||
      toast.error(res.message);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full p-2'>
 | 
			
		||||
      <div className='pt-1 flex gap-2 pb-4'>
 | 
			
		||||
        <Tooltip title='刷新本地模型配置项, 默认会缓存到浏览器。'>
 | 
			
		||||
          <IconButton onClick={() => store.getModelList(true)}>
 | 
			
		||||
            <RefreshCcw className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Divider orientation='vertical' />
 | 
			
		||||
        <Tooltip title='复制所有的消息'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              console.log(store.messages);
 | 
			
		||||
              copyText(JSON.stringify(store.messages));
 | 
			
		||||
            }}>
 | 
			
		||||
            <Copy className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Divider orientation='vertical' />
 | 
			
		||||
        <Tooltip title='清除自己的服务器缓存配置项'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              store.clearConfigCache();
 | 
			
		||||
            }}>
 | 
			
		||||
            <BookmarkX className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='清除当前自己模型的统计'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              clearSelfUsage();
 | 
			
		||||
            }}>
 | 
			
		||||
            <Trash className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Divider />
 | 
			
		||||
      <div
 | 
			
		||||
        className='flex flex-col gap-2 py-2 text-sm scrollbar overflow-y-auto'
 | 
			
		||||
        style={{
 | 
			
		||||
          height: 'calc(100% - 40px)',
 | 
			
		||||
        }}>
 | 
			
		||||
        <div>使用root的模型统计: token: {prettyBytes(usageData.rootUsage?.token || 0)}</div>
 | 
			
		||||
        <div className='py-2'>
 | 
			
		||||
          自己模型使用统计
 | 
			
		||||
          <div className='flex flex-col gap-2 pl-4 mt-3'>
 | 
			
		||||
            {usageData.selfUsage?.map((item) => {
 | 
			
		||||
              const keys = Object.keys(item);
 | 
			
		||||
              const defaultKeys = ['model', 'token', 'group'];
 | 
			
		||||
              const days = keys.filter((key) => !defaultKeys.includes(key));
 | 
			
		||||
              return (
 | 
			
		||||
                <div key={item.model} className='flex items-center gap-2'>
 | 
			
		||||
                  <div className='text-sm shadow-sm p-2 rounded-md w-full border border-gray-200'>
 | 
			
		||||
                    <div className='font-bold'>group: {item.group}</div>
 | 
			
		||||
                    <div className=' flex gap-2 items-center'>
 | 
			
		||||
                      <div className=' w-2 h-2 bg-secondary rounded-full' /> {item.model}
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div>token: {prettyBytes(item.token)}</div>
 | 
			
		||||
                    <div className='text-xs text-gray-500'>
 | 
			
		||||
                      {days.map((day) => {
 | 
			
		||||
                        return (
 | 
			
		||||
                          <div key={day}>
 | 
			
		||||
                            {day}: {item[day] || 0} 次
 | 
			
		||||
                          </div>
 | 
			
		||||
                        );
 | 
			
		||||
                      })}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              );
 | 
			
		||||
            })}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										115
									
								
								src/apps/ai-chat/components/RenderMarkdown.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/apps/ai-chat/components/RenderMarkdown.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,115 @@
 | 
			
		||||
import { throttle } from 'lodash-es';
 | 
			
		||||
import { Marked } from 'marked';
 | 
			
		||||
import 'highlight.js/styles/github.css'; // 你可以选择其他样式
 | 
			
		||||
import { useRef, useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
import { markedHighlight } from 'marked-highlight';
 | 
			
		||||
import hljs from 'highlight.js';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
 | 
			
		||||
const marked = new Marked(
 | 
			
		||||
  markedHighlight({
 | 
			
		||||
    emptyLangClass: 'hljs',
 | 
			
		||||
    langPrefix: 'hljs language-',
 | 
			
		||||
    highlight(code, lang, info) {
 | 
			
		||||
      const language = hljs.getLanguage(lang) ? lang : 'plaintext';
 | 
			
		||||
      return hljs.highlight(code, { language }).value;
 | 
			
		||||
    },
 | 
			
		||||
  }),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type ResponseTextProps = {
 | 
			
		||||
  response: Response;
 | 
			
		||||
  onFinish?: (text: string) => void;
 | 
			
		||||
  onChange?: (text: string) => void;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
};
 | 
			
		||||
export const ResponseText = (props: ResponseTextProps) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const render = async () => {
 | 
			
		||||
    const response = props.response;
 | 
			
		||||
    if (!response) return;
 | 
			
		||||
    const msg = ref.current!;
 | 
			
		||||
    if (!msg) {
 | 
			
		||||
      console.log('msg is null');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    await new Promise((resolve) => setTimeout(resolve, 100));
 | 
			
		||||
    const reader = response.body?.getReader();
 | 
			
		||||
    const decoder = new TextDecoder('utf-8');
 | 
			
		||||
    let done = false;
 | 
			
		||||
 | 
			
		||||
    while (!done) {
 | 
			
		||||
      const { value, done: streamDone } = await reader!.read();
 | 
			
		||||
      done = streamDone;
 | 
			
		||||
 | 
			
		||||
      if (value) {
 | 
			
		||||
        const chunk = decoder.decode(value, { stream: true });
 | 
			
		||||
        // 更新状态,实时刷新 UI
 | 
			
		||||
        msg.innerHTML += chunk;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (done) {
 | 
			
		||||
      props.onFinish && props.onFinish(msg.innerHTML);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    render();
 | 
			
		||||
  }, []);
 | 
			
		||||
  return <div id={props.id} className={clsx('response markdown-body', props.className)} ref={ref}></div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ResponseMarkdown = (props: ResponseTextProps) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  let content = '';
 | 
			
		||||
  const render = async () => {
 | 
			
		||||
    const response = props.response;
 | 
			
		||||
    if (!response) return;
 | 
			
		||||
    const msg = ref.current!;
 | 
			
		||||
    if (!msg) {
 | 
			
		||||
      console.log('msg is null');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    await new Promise((resolve) => setTimeout(resolve, 100));
 | 
			
		||||
    const reader = response.body?.getReader();
 | 
			
		||||
    const decoder = new TextDecoder('utf-8');
 | 
			
		||||
    let done = false;
 | 
			
		||||
 | 
			
		||||
    while (!done) {
 | 
			
		||||
      const { value, done: streamDone } = await reader!.read();
 | 
			
		||||
      done = streamDone;
 | 
			
		||||
 | 
			
		||||
      if (value) {
 | 
			
		||||
        const chunk = decoder.decode(value, { stream: true });
 | 
			
		||||
        content = content + chunk;
 | 
			
		||||
        renderThrottle(content);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (done) {
 | 
			
		||||
      props.onFinish && props.onFinish(msg.innerHTML);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  const renderThrottle = throttle(async (markdown: string) => {
 | 
			
		||||
    const msg = ref.current!;
 | 
			
		||||
    msg.innerHTML = await marked.parse(markdown);
 | 
			
		||||
    props.onChange?.(msg.innerHTML);
 | 
			
		||||
  }, 100);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    render();
 | 
			
		||||
  }, [props.response]);
 | 
			
		||||
  return <div id={props.id} className={clsx('response markdown-body', props.className)} ref={ref}></div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Markdown = (props: { className?: string; markdown: string; id?: string }) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const parse = async () => {
 | 
			
		||||
    const md = await marked.parse(props.markdown);
 | 
			
		||||
    const msg = ref.current!;
 | 
			
		||||
    msg.innerHTML = md;
 | 
			
		||||
  };
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    parse();
 | 
			
		||||
  }, []);
 | 
			
		||||
  return <div id={props.id} ref={ref} className={clsx('markdown-body', props.className)}></div>;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										18
									
								
								src/apps/ai-chat/editor/Editor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/apps/ai-chat/editor/Editor.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { TextEditor } from '@kevisual/markdown-editor/tiptap/editor.ts';
 | 
			
		||||
 | 
			
		||||
import { useEffect, useRef } from 'react';
 | 
			
		||||
import { getSuggestionItems } from './suggestion/item';
 | 
			
		||||
 | 
			
		||||
export const MarkdownEditor = ({ onSave }: { onSave: () => void }) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (ref.current) {
 | 
			
		||||
      const editor = new TextEditor();
 | 
			
		||||
      editor.createEditor(ref.current, {
 | 
			
		||||
        markdown: '## Hello World',
 | 
			
		||||
        items: getSuggestionItems(),
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
  return <div className='w-full h-full' ref={ref}></div>;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										25
									
								
								src/apps/ai-chat/editor/suggestion/item.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/apps/ai-chat/editor/suggestion/item.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
export interface CommandItem {
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  content: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getSuggestionItems = (): CommandItem[] => {
 | 
			
		||||
  return [
 | 
			
		||||
    {
 | 
			
		||||
      title: 'Now',
 | 
			
		||||
      description: 'Insert current time',
 | 
			
		||||
      content: new Date().toLocaleTimeString(),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: 'Today',
 | 
			
		||||
      description: "Insert today's date",
 | 
			
		||||
      content: new Date().toLocaleDateString(),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      title: 'Datetime',
 | 
			
		||||
      description: 'Insert current date and time',
 | 
			
		||||
      content: new Date().toLocaleString(),
 | 
			
		||||
    },
 | 
			
		||||
  ];
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										14
									
								
								src/apps/ai-chat/index.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/apps/ai-chat/index.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
@import 'tailwindcss';
 | 
			
		||||
@import '@kevisual/markdown-editor/index.css';
 | 
			
		||||
 | 
			
		||||
.sortable-swap-highlight {
 | 
			
		||||
  border: 1px solid red;
 | 
			
		||||
  background-color: aquamarine;
 | 
			
		||||
}
 | 
			
		||||
.sortable-ghost {
 | 
			
		||||
  @apply bg-gray-200 border border-gray-400 shadow-sm rounded-lg;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sortable-drag {
 | 
			
		||||
  @apply border bg-gray-500 border-gray-200 shadow-sm rounded-lg text-white;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										115
									
								
								src/apps/ai-chat/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/apps/ai-chat/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,115 @@
 | 
			
		||||
import { StoreContextProvider, useStore } from '@kevisual/store/react';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { createChatStore, useChatStore } from './store';
 | 
			
		||||
import { Messages } from './modules/MessageList';
 | 
			
		||||
import { useEffect, useLayoutEffect } from 'react';
 | 
			
		||||
import { ModelNav } from './modules/ModelNav';
 | 
			
		||||
import { Menu } from 'lucide-react';
 | 
			
		||||
import { IconButton } from '@/components/a/button.tsx';
 | 
			
		||||
import { ChatHistoryDialog } from './chat-history/Dialog';
 | 
			
		||||
import { getHistoryState } from '@kevisual/store/web-page.js';
 | 
			
		||||
import { ChatContextDialog } from './chat-context/Dialog';
 | 
			
		||||
import { ChatModelSettingDialog } from './chat-model-setting/Dialog';
 | 
			
		||||
import { ChatSettingDialog } from './chat-setting/Dialog';
 | 
			
		||||
import { ChatCopyDialog } from './chat-copy/Dialog';
 | 
			
		||||
import { ToastProvider } from '@/modules/toast/Provider';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { ChatHistoryList } from './chat-history/List';
 | 
			
		||||
import './index.css';
 | 
			
		||||
 | 
			
		||||
type AppProps = {
 | 
			
		||||
  storeId?: string;
 | 
			
		||||
};
 | 
			
		||||
export const App = (props: AppProps) => {
 | 
			
		||||
  const storeId = props.storeId || 'ai-chat-store';
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    // const token = localStorage.getItem('token');
 | 
			
		||||
    // if (!token) {
 | 
			
		||||
    //   toastLogin();
 | 
			
		||||
    // }
 | 
			
		||||
  }, []);
 | 
			
		||||
  return (
 | 
			
		||||
    <ToastProvider>
 | 
			
		||||
      <StoreContextProvider id={storeId} stateCreator={createChatStore}>
 | 
			
		||||
        <Chat storeId={storeId} />
 | 
			
		||||
      </StoreContextProvider>
 | 
			
		||||
    </ToastProvider>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Chat = ({ storeId }: { storeId: string }) => {
 | 
			
		||||
  const { id, init, messages, showList, setShowList, setId, getChatList, chatData } = useChatStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: state.id,
 | 
			
		||||
        init: state.init,
 | 
			
		||||
        messages: state.messages,
 | 
			
		||||
        showList: state.showList,
 | 
			
		||||
        setShowList: state.setShowList,
 | 
			
		||||
        setId: state.setId,
 | 
			
		||||
        getChatList: state.getChatList,
 | 
			
		||||
        chatData: state.chatData,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (showList) {
 | 
			
		||||
      getChatList();
 | 
			
		||||
    }
 | 
			
		||||
  }, [showList]);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    init(id);
 | 
			
		||||
  }, [id]);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const chat = getHistoryState()[storeId];
 | 
			
		||||
    const chatId = chat?.chatId;
 | 
			
		||||
    const url = new URL(window.location.href);
 | 
			
		||||
    const urlChatId = url.searchParams.get('chatId');
 | 
			
		||||
    if (chatId) {
 | 
			
		||||
      setId(chatId);
 | 
			
		||||
    } else if (urlChatId) {
 | 
			
		||||
      setId(urlChatId);
 | 
			
		||||
    } else {
 | 
			
		||||
      getChatList();
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='w-full h-full relative'>
 | 
			
		||||
      {!id && (
 | 
			
		||||
        <div className='w-full h-full flex flex-col justify-center items-center'>
 | 
			
		||||
          <ChatHistoryList storeId={storeId} />
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
      <div className={clsx('w-full h-full flex flex-col  ', !id && 'hidden')}>
 | 
			
		||||
        <IconButton
 | 
			
		||||
          className='absolute top-2 left-2 w-8 h-8'
 | 
			
		||||
          style={{
 | 
			
		||||
            position: 'absolute',
 | 
			
		||||
          }}
 | 
			
		||||
          onClick={() => setShowList(!showList)}>
 | 
			
		||||
          <Menu className='w-4 h-4' />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
 | 
			
		||||
        <div className='h-[56px] flex justify-between items-center px-6 w-full lg:w-[80%] mx-auto'>
 | 
			
		||||
          <ModelNav />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div
 | 
			
		||||
          className='relative overflow-auto scrollbar border border-gray-200 rounded shadow-sm mb-2 px-6 py-2 w-full lg:w-[80%] mx-auto'
 | 
			
		||||
          style={{
 | 
			
		||||
            height: 'calc(100% - 56px)',
 | 
			
		||||
          }}>
 | 
			
		||||
          <div className='mt-2 mb-5 min-h-[40px] relative'>
 | 
			
		||||
            <div className='text-sm text-gray-500 border-b border-gray-200 pb-2 truncate'>{chatData?.title}</div>
 | 
			
		||||
            <Messages messages={messages} />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <ChatContextDialog />
 | 
			
		||||
      <ChatHistoryDialog storeId={storeId} />
 | 
			
		||||
      <ChatModelSettingDialog />
 | 
			
		||||
      <ChatSettingDialog />
 | 
			
		||||
      <ChatCopyDialog />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										199
									
								
								src/apps/ai-chat/modules/MessageList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								src/apps/ai-chat/modules/MessageList.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,199 @@
 | 
			
		||||
import { ResponseMarkdown, Markdown } from '@/components/ai/RenderMarkdown';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { Actions } from './actions/Actions';
 | 
			
		||||
import { ChastHistoryMessage } from '../store/type';
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { IconButton } from '@/components/a/button';
 | 
			
		||||
import { Eraser, Move, Plus } from 'lucide-react';
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip';
 | 
			
		||||
import { ReactSortable } from 'react-sortablejs';
 | 
			
		||||
import { useEffect, useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
export type MessageData = {
 | 
			
		||||
  content: string;
 | 
			
		||||
  role: 'user' | 'assistant' | 'system';
 | 
			
		||||
  response?: Response | null;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  onFinish?: (text: string) => void;
 | 
			
		||||
  // onFinish之前,每200ms调用一次
 | 
			
		||||
  onChange?: (text: string) => void;
 | 
			
		||||
  id?: string;
 | 
			
		||||
  randomId?: string;
 | 
			
		||||
};
 | 
			
		||||
export type MessageProps = { data: ChastHistoryMessage } & MessageData;
 | 
			
		||||
export const AssistantMessage = (props: MessageProps) => {
 | 
			
		||||
  if (props.response) {
 | 
			
		||||
    return <ResponseMarkdown className={clsx(props.className)} onChange={props.onChange} response={props.response} onFinish={props.onFinish} />;
 | 
			
		||||
  }
 | 
			
		||||
  return <Markdown className={clsx(props.className)} markdown={props.content} />;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Message = (props: MessageProps) => {
 | 
			
		||||
  const isUser = props.role === 'user';
 | 
			
		||||
  const isHide = !!props.data.hide;
 | 
			
		||||
  const isNoUse = !!props.data.noUse;
 | 
			
		||||
  const { showAllContext, updateMessage } = useChatStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        showAllContext: state.showAllContext,
 | 
			
		||||
        updateMessage: state.updateMessage,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  if (!showAllContext && isHide) {
 | 
			
		||||
    // return <div className=''></div>;
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  const isNotShow = !showAllContext && isHide;
 | 
			
		||||
  const isContext = !isHide && !isNoUse;
 | 
			
		||||
  const onNoUseChange = () => {
 | 
			
		||||
    const data = props.data;
 | 
			
		||||
    const newData = { ...data, noUse: !data.noUse };
 | 
			
		||||
    updateMessage(newData);
 | 
			
		||||
  };
 | 
			
		||||
  // const imgUrl = '/root/center/panda.jpg'
 | 
			
		||||
  const imgUrl = 'https://kevisual.xiongxiao.me/root/center/panda.jpg';
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={clsx('py-2  px-2 mt-1', props.className, isContext && 'shadow-md border-b border-gray-300 box-content rounded-sm', isNotShow && 'hidden')}>
 | 
			
		||||
      <div className={clsx('chat-message')}>
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            {
 | 
			
		||||
              'message-user': isUser,
 | 
			
		||||
              'message-assistant': !isUser,
 | 
			
		||||
            },
 | 
			
		||||
            'flex flex-col',
 | 
			
		||||
          )}>
 | 
			
		||||
          <div>
 | 
			
		||||
            <div className=' flex gap-4 group items-center'>
 | 
			
		||||
              <div
 | 
			
		||||
                className='cursor-pointer'
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  onNoUseChange();
 | 
			
		||||
                }}>
 | 
			
		||||
                <div className={clsx('message-avatar flex gap-3 items-center py-2', isUser && 'hidden')}>
 | 
			
		||||
                  <img src={imgUrl} alt='' className='w-6 h-6 rounded-full' />
 | 
			
		||||
                  <div className='' style={{ textTransform: 'capitalize' }}>
 | 
			
		||||
                    {props.role}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className={clsx('message-avatar flex gap-3 items-center py-2', !isUser && 'hidden')}>
 | 
			
		||||
                  <img src={imgUrl} alt='' className='w-6 h-6 rounded-full' />
 | 
			
		||||
                  <div
 | 
			
		||||
                    className=''
 | 
			
		||||
                    style={{
 | 
			
		||||
                      textTransform: 'capitalize',
 | 
			
		||||
                    }}>
 | 
			
		||||
                    {props.role}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className='message-move hidden group-hover:block'>
 | 
			
		||||
                <div className='flex gap-2 cursor-pointer'>
 | 
			
		||||
                  <Move className='w-4 h-4' />
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className={clsx('message-content scrollbar overflow-x-hidden', isHide && 'opacity-50')}>
 | 
			
		||||
              <AssistantMessage {...props} className='p-2 rounded' />
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Actions data={props.data} />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
export const Messages = (props: { messages: ChastHistoryMessage[] }) => {
 | 
			
		||||
  const { messages } = props;
 | 
			
		||||
  const { setMessages, setContextMessage, setShowContext, showAllContext } = useChatStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        showAllContext: state.showAllContext,
 | 
			
		||||
        setMessages: state.setMessages,
 | 
			
		||||
        setContextMessage: state.setContextMessage,
 | 
			
		||||
        setShowContext: state.setShowContext,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const onEraser = () => {
 | 
			
		||||
    const newMessages = messages.map((message) => {
 | 
			
		||||
      return {
 | 
			
		||||
        ...message,
 | 
			
		||||
        noUse: true,
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
    setMessages([...newMessages]);
 | 
			
		||||
  };
 | 
			
		||||
  const list = messages.filter((message) => {
 | 
			
		||||
    const isHide = !!message.hide;
 | 
			
		||||
    return !(!showAllContext && isHide);
 | 
			
		||||
  });
 | 
			
		||||
  const reactSortable = useMemo(() => {
 | 
			
		||||
    return (
 | 
			
		||||
      <ReactSortable
 | 
			
		||||
        handle='.message-move'
 | 
			
		||||
        list={list as any}
 | 
			
		||||
        setList={(newList) => {}}
 | 
			
		||||
        onEnd={(event) => {
 | 
			
		||||
          const { newIndex, oldIndex } = event;
 | 
			
		||||
          const newMessages = [...messages];
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          const _oldIndex = messages.findIndex((item) => item.id === list[oldIndex].id);
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          const _newIndex = messages.findIndex((item) => item.id === list[newIndex].id);
 | 
			
		||||
          const tmp = newMessages[_oldIndex];
 | 
			
		||||
          newMessages[_oldIndex] = newMessages[_newIndex];
 | 
			
		||||
          newMessages[_newIndex] = tmp;
 | 
			
		||||
          setMessages(newMessages);
 | 
			
		||||
        }}
 | 
			
		||||
        direction={'vertical'}
 | 
			
		||||
        swap={true}
 | 
			
		||||
        chosenClass='sortable-chosen'
 | 
			
		||||
        dragClass='sortable-drag'
 | 
			
		||||
        swapThreshold={0.6}
 | 
			
		||||
        animation={200}>
 | 
			
		||||
        {messages.map((message) => {
 | 
			
		||||
          const id = message.id;
 | 
			
		||||
          const onChange = (text: string) => {};
 | 
			
		||||
          const onFinish = (text: string) => {};
 | 
			
		||||
          return (
 | 
			
		||||
            <Message
 | 
			
		||||
              className='message-item'
 | 
			
		||||
              content={message.content}
 | 
			
		||||
              role={message.role as any}
 | 
			
		||||
              data={message}
 | 
			
		||||
              id={id}
 | 
			
		||||
              key={id + String(message.updatedAt)}
 | 
			
		||||
              onChange={onChange}
 | 
			
		||||
              onFinish={onFinish}
 | 
			
		||||
            />
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </ReactSortable>
 | 
			
		||||
    );
 | 
			
		||||
  }, [messages, list]);
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {reactSortable}
 | 
			
		||||
      <div className='flex py-2 gap-2'>
 | 
			
		||||
        <Tooltip title='添加新的上下文'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setContextMessage(undefined);
 | 
			
		||||
              setShowContext(true);
 | 
			
		||||
            }}>
 | 
			
		||||
            <Plus className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='清理所有上下文'>
 | 
			
		||||
          <IconButton onClick={onEraser}>
 | 
			
		||||
            <Eraser className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										118
									
								
								src/apps/ai-chat/modules/ModelNav.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/apps/ai-chat/modules/ModelNav.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,118 @@
 | 
			
		||||
import { Copy, Eye, EyeOff, Menu, Plus, Save, Send, Settings, Trash } from 'lucide-react';
 | 
			
		||||
import { useChatStore } from '../store';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { IconButton } from '@/components/a/button.tsx';
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip.tsx';
 | 
			
		||||
import { Divider } from '@/components/a/divider';
 | 
			
		||||
 | 
			
		||||
export const ModelNav = () => {
 | 
			
		||||
  const {
 | 
			
		||||
    id,
 | 
			
		||||
    messages,
 | 
			
		||||
    showAllContext,
 | 
			
		||||
    setShowAllContext,
 | 
			
		||||
    setShowContext,
 | 
			
		||||
    setContextMessage,
 | 
			
		||||
    updateChat,
 | 
			
		||||
    currentUserModel,
 | 
			
		||||
    setShowChatSetting,
 | 
			
		||||
    chat,
 | 
			
		||||
    setShowSetting,
 | 
			
		||||
    setShowCopy,
 | 
			
		||||
  } = useChatStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: state.id,
 | 
			
		||||
        messages: state.messages,
 | 
			
		||||
        showAllContext: state.showAllContext,
 | 
			
		||||
        setShowAllContext: state.setShowAllContext,
 | 
			
		||||
        showContext: state.showContext,
 | 
			
		||||
        setShowContext: state.setShowContext,
 | 
			
		||||
        setContextMessage: state.setContextMessage,
 | 
			
		||||
        updateChat: state.updateChat,
 | 
			
		||||
        currentUserModel: state.currentUserModel,
 | 
			
		||||
        setShowChatSetting: state.setShowChatSetting,
 | 
			
		||||
        chat: state.chat,
 | 
			
		||||
        setShowSetting: state.setShowSetting,
 | 
			
		||||
        setShowCopy: state.setShowCopy,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='flex gap-2 items-center'>
 | 
			
		||||
      <div className='hidden lg:block font-bold text-gray-600'>提示词规划器</div>
 | 
			
		||||
      <div className='flex gap-2 items-center' onClick={() => setShowChatSetting(true)}>
 | 
			
		||||
        {currentUserModel && (
 | 
			
		||||
          <Tooltip
 | 
			
		||||
            title={
 | 
			
		||||
              <div className='p-2'>
 | 
			
		||||
                使用 [ {currentUserModel.username} ] 用户的对话模型
 | 
			
		||||
                <br /> Group: {currentUserModel.model?.group}
 | 
			
		||||
                <br /> Provider: {currentUserModel.model?.provider}
 | 
			
		||||
                <br /> Model: {currentUserModel.model?.model}
 | 
			
		||||
              </div>
 | 
			
		||||
            }>
 | 
			
		||||
            <div className='flex gap-2 items-center border border-gray-300 rounded-md p-2 max-w-[200px]  cursor-pointer'>
 | 
			
		||||
              <div className='truncate'>{currentUserModel.model?.model}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
        )}
 | 
			
		||||
        {!currentUserModel && <div className='text-sm text-gray-500 cursor-pointer'>Not Selected Model</div>}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className='flex gap-2 items-center'>
 | 
			
		||||
        {/* <Tooltip title='设置'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setShowSetting(true);
 | 
			
		||||
            }}>
 | 
			
		||||
            <Settings className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip> */}
 | 
			
		||||
        <Tooltip title={showAllContext ? '显示所有内容' : '不显示隐藏的内容'}>
 | 
			
		||||
          <IconButton onClick={() => setShowAllContext(!showAllContext)}>
 | 
			
		||||
            {showAllContext ? <Eye className='w-4 h-4' /> : <EyeOff className='w-4 h-4' />}
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='复制上下文'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setShowCopy(true);
 | 
			
		||||
            }}>
 | 
			
		||||
            <Copy className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='添加上下文'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setContextMessage(undefined);
 | 
			
		||||
              setShowContext(true);
 | 
			
		||||
            }}>
 | 
			
		||||
            <Plus className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Divider orientation='vertical' />
 | 
			
		||||
        <Tooltip title='保存, 默认只在本地编辑,请手动保存。'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              updateChat({
 | 
			
		||||
                id: id,
 | 
			
		||||
                data: {
 | 
			
		||||
                  messages: messages,
 | 
			
		||||
                },
 | 
			
		||||
              });
 | 
			
		||||
            }}>
 | 
			
		||||
            <Save className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
        <Tooltip title='发送请求。'>
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              chat();
 | 
			
		||||
            }}>
 | 
			
		||||
            <Send className='w-4 h-4' />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										71
									
								
								src/apps/ai-chat/modules/actions/Actions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/apps/ai-chat/modules/actions/Actions.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
import { useChatStore } from '@/apps/ai-chat/store';
 | 
			
		||||
import { MessageProps } from '../MessageList';
 | 
			
		||||
import { Edit, EyeOff, Eye, Trash, MonitorOff, Monitor, Copy } from 'lucide-react';
 | 
			
		||||
import { useShallow } from 'zustand/shallow';
 | 
			
		||||
import { ChastHistoryMessage } from '@/apps/ai-chat/store/type';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { Tooltip } from '@/components/a/tooltip';
 | 
			
		||||
import { copyText } from '@/apps/ai-chat/utils/copy';
 | 
			
		||||
import { Confirm } from '@/components/a/confirm';
 | 
			
		||||
type ActionsProps = { data: ChastHistoryMessage };
 | 
			
		||||
export const Actions = (props: ActionsProps) => {
 | 
			
		||||
  const { data } = props;
 | 
			
		||||
 | 
			
		||||
  const { deleteMessage, updateMessage, setContextMessage, contextMessage, setShowContext } = useChatStore(
 | 
			
		||||
    useShallow((state) => {
 | 
			
		||||
      return {
 | 
			
		||||
        deleteMessage: state.deleteMessage,
 | 
			
		||||
        updateMessage: state.updateMessage,
 | 
			
		||||
        setContextMessage: state.setContextMessage,
 | 
			
		||||
        contextMessage: state.contextMessage,
 | 
			
		||||
        setShowContext: state.setShowContext,
 | 
			
		||||
      };
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  const onEyeChange = () => {
 | 
			
		||||
    const newData = { ...data, hide: !data.hide };
 | 
			
		||||
    updateMessage(newData);
 | 
			
		||||
  };
 | 
			
		||||
  const onDelete = () => {
 | 
			
		||||
    data.id && deleteMessage(data.id);
 | 
			
		||||
  };
 | 
			
		||||
  const onNoUseChange = () => {
 | 
			
		||||
    const newData = { ...data, noUse: !data.noUse };
 | 
			
		||||
    updateMessage(newData);
 | 
			
		||||
  };
 | 
			
		||||
  const isEyeHide = data.hide;
 | 
			
		||||
  const isNoUse = data.noUse;
 | 
			
		||||
  const EyeIcon = isEyeHide ? Eye : EyeOff;
 | 
			
		||||
  const MonitorIcon = isNoUse ? Monitor : MonitorOff;
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={clsx('flex gap-4 py-2 mt-1 px-2')}>
 | 
			
		||||
      <Tooltip title='编辑'>
 | 
			
		||||
        <Edit
 | 
			
		||||
          className='w-4 h-4 cursor-pointer hover:text-primary'
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            setContextMessage(data);
 | 
			
		||||
            setShowContext(true);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
      <Tooltip title='复制'>
 | 
			
		||||
        <Copy className='w-4 h-4 cursor-pointer hover:text-primary' onClick={() => copyText(data.content)} />
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
      <Tooltip title={isEyeHide ? '显示' : '隐藏'}>
 | 
			
		||||
        <EyeIcon className='w-4 h-4 cursor-pointer hover:text-primary' onClick={onEyeChange} />
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
      <Tooltip title={isNoUse ? '启用上下文' : '禁用上下文'}>
 | 
			
		||||
        <MonitorIcon className='w-4 h-4 cursor-pointer hover:text-primary' onClick={onNoUseChange} />
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
      <Tooltip title='完全删除'>
 | 
			
		||||
        <Confirm
 | 
			
		||||
          title='确认删除'
 | 
			
		||||
          onOk={() => {
 | 
			
		||||
            onDelete();
 | 
			
		||||
          }}>
 | 
			
		||||
          <Trash className='w-4 h-4 cursor-pointer hover:text-primary' />
 | 
			
		||||
        </Confirm>
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										153
									
								
								src/apps/ai-chat/query/chat.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								src/apps/ai-chat/query/chat.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,153 @@
 | 
			
		||||
import { query } from '@/modules/query';
 | 
			
		||||
import { Query } from '@kevisual/query';
 | 
			
		||||
import { DataOpts } from '@kevisual/query/query';
 | 
			
		||||
 | 
			
		||||
export class QueryChat {
 | 
			
		||||
  query: Query;
 | 
			
		||||
 | 
			
		||||
  constructor({ query }: { query: Query }) {
 | 
			
		||||
    this.query = query;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getChatList(opts?: DataOpts) {
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'get-chat-list',
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getChat(id: string, opts?: DataOpts) {
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'get-chat',
 | 
			
		||||
        data: {
 | 
			
		||||
          id,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  updateChat(data: any, opts?: DataOpts) {
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'update-chat',
 | 
			
		||||
        data,
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  deleteChat(id: string, opts?: DataOpts) {
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'delete-chat',
 | 
			
		||||
        data: {
 | 
			
		||||
          id,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 获取模型列表
 | 
			
		||||
   * @param opts
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  getModelList(data?: { usernames?: string[] }, opts?: DataOpts) {
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'get-model-list',
 | 
			
		||||
        data,
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 聊天对话模型
 | 
			
		||||
   * @param data
 | 
			
		||||
   * @param chatOpts
 | 
			
		||||
   * @param opts
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  chat(data: ChatDataOpts, chatOpts: ChatOpts, opts?: DataOpts) {
 | 
			
		||||
    const { username, model, group, getFull = true } = chatOpts;
 | 
			
		||||
    if (!username || !model || !group) {
 | 
			
		||||
      throw new Error('username, model, group is required');
 | 
			
		||||
    }
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'chat',
 | 
			
		||||
        ...chatOpts,
 | 
			
		||||
        getFull,
 | 
			
		||||
        data,
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  clearConfigCache(opts?: DataOpts) {
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'clear-cache',
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 获取聊天使用情况
 | 
			
		||||
   * @param opts
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  getChatUsage(opts?: DataOpts) {
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'get-chat-usage',
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 清除当前用户模型自己的统计
 | 
			
		||||
   * @param opts
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  clearSelfUsage(opts?: DataOpts) {
 | 
			
		||||
    return this.query.post(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'ai',
 | 
			
		||||
        key: 'clear-chat-limit',
 | 
			
		||||
      },
 | 
			
		||||
      opts,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
export type ChatDataOpts = {
 | 
			
		||||
  id?: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  messages?: any[];
 | 
			
		||||
  data?: any;
 | 
			
		||||
  type?: 'temp' | 'keep' | string;
 | 
			
		||||
};
 | 
			
		||||
export type ChatOpts = {
 | 
			
		||||
  username: string;
 | 
			
		||||
  model: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * 获取完整消息回复
 | 
			
		||||
   */
 | 
			
		||||
  getFull?: boolean;
 | 
			
		||||
  group: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * openai的参数
 | 
			
		||||
   */
 | 
			
		||||
  options?: any;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										61
									
								
								src/apps/ai-chat/store/demo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/apps/ai-chat/store/demo.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
import { ChastHistoryMessage } from './type';
 | 
			
		||||
 | 
			
		||||
export const messagesDemo1: ChastHistoryMessage[] = [
 | 
			
		||||
  {
 | 
			
		||||
    role: 'user',
 | 
			
		||||
    content: '你好',
 | 
			
		||||
    name: '用户',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    role: 'assistant',
 | 
			
		||||
    content: '你好,我是AI助手,很高兴认识你!',
 | 
			
		||||
    name: 'AI',
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export const messagesDemo2: ChastHistoryMessage[] = [
 | 
			
		||||
  {
 | 
			
		||||
    role: 'user',
 | 
			
		||||
    content: '你能帮我解释一下什么是人工智能吗?',
 | 
			
		||||
    name: '用户',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    role: 'assistant',
 | 
			
		||||
    content:
 | 
			
		||||
      '人工智能(AI)是计算机科学的一个分支,致力于创建能够模拟人类智能行为的系统。这些系统可以学习、推理、感知环境、理解语言并解决问题。目前常见的AI应用包括语音助手、推荐系统、自动驾驶和自然语言处理等。需要了解更具体的AI领域吗?',
 | 
			
		||||
    name: 'AI助手',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    role: 'user',
 | 
			
		||||
    content: '机器学习和深度学习有什么区别?',
 | 
			
		||||
    name: '用户',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    role: 'assistant',
 | 
			
		||||
    content:
 | 
			
		||||
      '机器学习和深度学习的主要区别是:\n\n1. **机器学习**是AI的一个子集,使用统计方法让计算机系统通过数据"学习"并改进性能,无需明确编程。常见的机器学习算法包括决策树、支持向量机和随机森林等。\n\n2. **深度学习**是机器学习的一个子集,使用多层神经网络(称为深度神经网络)来模拟人脑的工作方式。它能自动从大量数据中提取特征,特别适合处理图像识别、自然语言处理等复杂任务。\n\n简单来说,深度学习是机器学习的一种高级形式,通过更复杂的模型结构处理更庞大的数据集。',
 | 
			
		||||
    name: 'AI助手',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    role: 'user',
 | 
			
		||||
    content: '你能给我写一个简单的Python函数来计算斐波那契数列吗?',
 | 
			
		||||
    name: '用户',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    role: 'assistant',
 | 
			
		||||
    content:
 | 
			
		||||
      '当然,这里有两种计算斐波那契数列的Python函数:\n\n```python\n# 递归方法(简单但效率较低)\ndef fibonacci_recursive(n):\n    if n <= 0:\n        return 0\n    elif n == 1:\n        return 1\n    else:\n        return fibonacci_recursive(n-1) + fibonacci_recursive(n-2)\n\n# 迭代方法(更高效)\ndef fibonacci_iterative(n):\n    if n <= 0:\n        return 0\n    elif n == 1:\n        return 1\n    \n    a, b = 0, 1\n    for _ in range(2, n+1):\n        a, b = b, a + b\n    return b\n```\n\n迭代方法在处理较大的n时效率更高,因为它避免了递归方法中的重复计算问题。',
 | 
			
		||||
    name: 'AI助手',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    role: 'user',
 | 
			
		||||
    content: '谢谢你的帮助!今天的天气真不错。',
 | 
			
		||||
    name: '用户',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    role: 'assistant',
 | 
			
		||||
    content:
 | 
			
		||||
      '不客气,很高兴能帮到你!确实,好天气总能让人心情愉快。如果你有任何其他问题,无论是关于编程、AI还是其他话题,都欢迎随时向我咨询。祝你度过美好的一天!',
 | 
			
		||||
    name: 'AI助手',
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
							
								
								
									
										91
									
								
								src/apps/ai-chat/store/generte-text.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/apps/ai-chat/store/generte-text.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
			
		||||
/**
 | 
			
		||||
 * message type 是 {role: 'user' | 'assistant' | 'system', content: string, hide: boolean, noUse: boolean}
 | 
			
		||||
 * @param messages
 | 
			
		||||
 * @returns
 | 
			
		||||
 */
 | 
			
		||||
export const generateTextPrompt = (messages) => {
 | 
			
		||||
  const newMessages = [...messages].filter((message) => !message.hide && !message.noUse) as any;
 | 
			
		||||
  const lastReverseUserMessageIndex = newMessages.findLastIndex((message) => message.role === 'user');
 | 
			
		||||
 | 
			
		||||
  const text = newMessages
 | 
			
		||||
    .slice(0, lastReverseUserMessageIndex)
 | 
			
		||||
    .map((message) => {
 | 
			
		||||
      return `----\n${message.role}: \n ${message.content}\n----`;
 | 
			
		||||
    })
 | 
			
		||||
    .join('\n');
 | 
			
		||||
  let prompt = `
 | 
			
		||||
你是一个ai对话的助手,请根据用户的问题和用户的历史对话,给出最合适的回答。
 | 
			
		||||
 | 
			
		||||
用户的问题是:\n
 | 
			
		||||
${newMessages[lastReverseUserMessageIndex].content}
 | 
			
		||||
 | 
			
		||||
用户的历史对话是:
 | 
			
		||||
${text} \n
 | 
			
		||||
  `;
 | 
			
		||||
  return prompt;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * message type 是 {role: 'user' | 'assistant' | 'system', content: string, hide: boolean, noUse: boolean}
 | 
			
		||||
 * @param messages
 | 
			
		||||
 * @returns
 | 
			
		||||
 */
 | 
			
		||||
export const generateText = (messages) => {
 | 
			
		||||
  const newMessages = [...messages].filter((message) => !message.hide && !message.noUse);
 | 
			
		||||
 | 
			
		||||
  const text = newMessages
 | 
			
		||||
    .map((message) => {
 | 
			
		||||
      return '----' + message.role + '\n' + message.content + '\n----';
 | 
			
		||||
    })
 | 
			
		||||
    .join('\n');
 | 
			
		||||
 | 
			
		||||
  return text;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getCodeTemplate = () => {
 | 
			
		||||
  return `/**
 | 
			
		||||
 * message type 是 {role: 'user' | 'assistant' | 'system', content: string, hide: boolean, noUse: boolean}
 | 
			
		||||
 * @param messages
 | 
			
		||||
 * @returns
 | 
			
		||||
 */
 | 
			
		||||
export const generateText = (messages) => {
 | 
			
		||||
  const newMessages = [...messages].filter((message) => !message.hide && !message.noUse);
 | 
			
		||||
 | 
			
		||||
  const text = newMessages
 | 
			
		||||
    .map((message) => {
 | 
			
		||||
      return '----' + message.role + '\\n' + message.content + '\\n----';
 | 
			
		||||
    })
 | 
			
		||||
    .join('\\n');
 | 
			
		||||
 | 
			
		||||
  return text;
 | 
			
		||||
};
 | 
			
		||||
  `;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const runCode = (code) => {
 | 
			
		||||
  // 创建一个Blob对象,包含要执行的代码
 | 
			
		||||
  const blob = new Blob([code], { type: 'application/javascript' });
 | 
			
		||||
 | 
			
		||||
  // 将代码转换为URL
 | 
			
		||||
  const codeUrl = URL.createObjectURL(blob);
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    // 使用动态import导入代码
 | 
			
		||||
    return import(/* @vite-ignore */ codeUrl)
 | 
			
		||||
      .then((module) => {
 | 
			
		||||
        // 导入成功后释放URL
 | 
			
		||||
        console.log('module', module);
 | 
			
		||||
        URL.revokeObjectURL(codeUrl);
 | 
			
		||||
        return module;
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        // 发生错误时也释放URL
 | 
			
		||||
        URL.revokeObjectURL(codeUrl);
 | 
			
		||||
        throw new Error(`代码执行失败: ${error.message}`);
 | 
			
		||||
      });
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    // 捕获任何其他错误
 | 
			
		||||
    URL.revokeObjectURL(codeUrl);
 | 
			
		||||
    throw new Error(`无法导入代码: ${error.message}`);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										313
									
								
								src/apps/ai-chat/store/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										313
									
								
								src/apps/ai-chat/store/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,313 @@
 | 
			
		||||
import { StoreManager } from '@kevisual/store';
 | 
			
		||||
import { useContextKey } from '@kevisual/store/context';
 | 
			
		||||
import { StateCreator } from 'zustand';
 | 
			
		||||
import { query } from '@/modules/query';
 | 
			
		||||
import { useStore, BoundStore } from '@kevisual/store/react';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import { ChastHistoryMessage } from './type';
 | 
			
		||||
import { QueryChat } from '../query/chat';
 | 
			
		||||
import { CacheStore } from '@kevisual/cache/cache-store';
 | 
			
		||||
import { QueryMark } from '@/query/query-mark/query-mark';
 | 
			
		||||
export const queryMark = new QueryMark({ query: query, markType: 'chat' });
 | 
			
		||||
export const queryChat = new QueryChat({ query });
 | 
			
		||||
 | 
			
		||||
const dbName = 'chat';
 | 
			
		||||
export const store = useContextKey('store', () => {
 | 
			
		||||
  return new StoreManager();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
type ChatStore = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  setId: (id: string) => void;
 | 
			
		||||
  loading: boolean;
 | 
			
		||||
  setLoading: (loading: boolean) => void;
 | 
			
		||||
  messages: ChastHistoryMessage[];
 | 
			
		||||
  updateTime: number;
 | 
			
		||||
  setMessages: (messages: ChastHistoryMessage[], needCache?: boolean) => void;
 | 
			
		||||
 | 
			
		||||
  init: (id: string) => void;
 | 
			
		||||
  /**
 | 
			
		||||
   * 删除消息
 | 
			
		||||
   * @param index 消息索引
 | 
			
		||||
   */
 | 
			
		||||
  deleteMessage: (id: string) => void;
 | 
			
		||||
  /**
 | 
			
		||||
   * 清空消息
 | 
			
		||||
   */
 | 
			
		||||
  clearMessage: () => void;
 | 
			
		||||
  /**
 | 
			
		||||
   * 更新消息
 | 
			
		||||
   * @param message 消息
 | 
			
		||||
   */
 | 
			
		||||
  updateMessage: (message: ChastHistoryMessage) => void;
 | 
			
		||||
 | 
			
		||||
  showAllContext: boolean;
 | 
			
		||||
  setShowAllContext: (showAllContext: boolean) => void;
 | 
			
		||||
 | 
			
		||||
  showChatSetting: boolean;
 | 
			
		||||
  setShowChatSetting: (showChatSetting: boolean) => void;
 | 
			
		||||
 | 
			
		||||
  showList: boolean;
 | 
			
		||||
  setShowList: (showList: boolean) => void;
 | 
			
		||||
  newChat: boolean;
 | 
			
		||||
  setNewChat: (newChat: boolean) => void;
 | 
			
		||||
 | 
			
		||||
  historyList: any[];
 | 
			
		||||
  setHistoryList: (historyList: any[]) => void;
 | 
			
		||||
 | 
			
		||||
  chatData: any;
 | 
			
		||||
  setChatData: (chatData: any) => void;
 | 
			
		||||
 | 
			
		||||
  getChatList: () => Promise<any>;
 | 
			
		||||
  updateChat: (data: any) => Promise<any>;
 | 
			
		||||
  deleteChat: (id: string) => Promise<any>;
 | 
			
		||||
 | 
			
		||||
  // 上下文对话框编辑
 | 
			
		||||
  showContext: boolean;
 | 
			
		||||
  setShowContext: (showContext: boolean) => void;
 | 
			
		||||
  contextMessage?: ChastHistoryMessage;
 | 
			
		||||
  setContextMessage: (contextMessage?: ChastHistoryMessage) => void;
 | 
			
		||||
 | 
			
		||||
  currentUserModel?: { username: string; model: { group: string; provider: string; model: string } };
 | 
			
		||||
  setCurrentUserModel: (currentUserModel: { username: string; model: { group: string; provider: string; model: string } }) => void;
 | 
			
		||||
  modelList: any[];
 | 
			
		||||
  setModelList: (modelList: any[]) => void;
 | 
			
		||||
  getModelList: (force?: boolean) => Promise<any>;
 | 
			
		||||
  chat: () => Promise<any>;
 | 
			
		||||
  clearConfigCache: () => Promise<any>;
 | 
			
		||||
 | 
			
		||||
  modelId: string;
 | 
			
		||||
  setModelId: (modelId: string) => void;
 | 
			
		||||
 | 
			
		||||
  showSetting: boolean;
 | 
			
		||||
  setShowSetting: (showSetting: boolean) => void;
 | 
			
		||||
 | 
			
		||||
  showCopy: boolean;
 | 
			
		||||
  setShowCopy: (showCopy: boolean) => void;
 | 
			
		||||
};
 | 
			
		||||
export const createChatStore: StateCreator<ChatStore, [], [], ChatStore> = (set, get, store) => {
 | 
			
		||||
  return {
 | 
			
		||||
    id: '',
 | 
			
		||||
    setId: (id: string) => set(() => ({ id })),
 | 
			
		||||
    loading: false,
 | 
			
		||||
    setLoading: (loading: boolean) => set(() => ({ loading })),
 | 
			
		||||
    updateTime: 0,
 | 
			
		||||
    messages: [],
 | 
			
		||||
    setMessages: (messages: ChastHistoryMessage[], needCache = true) => {
 | 
			
		||||
      const id = get().id;
 | 
			
		||||
      if (needCache) {
 | 
			
		||||
        const chatData = get().chatData;
 | 
			
		||||
        if (chatData) {
 | 
			
		||||
          const data = chatData.data || {};
 | 
			
		||||
          data.messages = messages;
 | 
			
		||||
          chatData.data = data;
 | 
			
		||||
          const cache = new CacheStore({ dbName });
 | 
			
		||||
          cache.set(id, chatData);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      const now = Date.now();
 | 
			
		||||
      set(() => ({ updateTime: now }));
 | 
			
		||||
      set({ messages });
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    init: async (id: string) => {
 | 
			
		||||
      if (!id) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      // clear default
 | 
			
		||||
      const { setMessages } = get();
 | 
			
		||||
      setMessages([], false);
 | 
			
		||||
 | 
			
		||||
      const { getModelList, setCurrentUserModel } = get();
 | 
			
		||||
      await getModelList();
 | 
			
		||||
      const cache = new CacheStore({ dbName });
 | 
			
		||||
      const chatData = await cache.get(id);
 | 
			
		||||
      const currentUserModel = await cache.get('currentUserModel');
 | 
			
		||||
      if (currentUserModel) {
 | 
			
		||||
        setCurrentUserModel(currentUserModel);
 | 
			
		||||
      }
 | 
			
		||||
      if (chatData) {
 | 
			
		||||
        const messages = chatData.data?.messages || [];
 | 
			
		||||
        set(() => ({ messages: messages, chatData }));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const res = await queryMark.getMark(id);
 | 
			
		||||
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        const resChatData = res.data as any;
 | 
			
		||||
        if (chatData && resChatData.updatedAt === chatData.updatedAt) {
 | 
			
		||||
          console.log('no update', 'time is ', resChatData.updatedAt, chatData.updatedAt);
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        console.log('update chat data', resChatData);
 | 
			
		||||
        get().setMessages(resChatData?.data?.messages || []);
 | 
			
		||||
        set(() => ({ chatData: resChatData }));
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error(res.message);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    deleteMessage: (id: string) => {
 | 
			
		||||
      const { messages, setMessages } = get();
 | 
			
		||||
      const newMessages = messages.filter((m) => m.id !== id);
 | 
			
		||||
      setMessages(newMessages);
 | 
			
		||||
    },
 | 
			
		||||
    clearMessage: () => {
 | 
			
		||||
      const { setMessages } = get();
 | 
			
		||||
      setMessages([]);
 | 
			
		||||
    },
 | 
			
		||||
    updateMessage: (message: ChastHistoryMessage) => {
 | 
			
		||||
      const { messages, setMessages } = get();
 | 
			
		||||
      let isNew = true;
 | 
			
		||||
      let newMessages = messages.map((m) => {
 | 
			
		||||
        if (m.id === message.id) {
 | 
			
		||||
          isNew = false;
 | 
			
		||||
          return { ...m, ...message, updatedAt: new Date().getTime() };
 | 
			
		||||
        }
 | 
			
		||||
        return m;
 | 
			
		||||
      });
 | 
			
		||||
      if (isNew) {
 | 
			
		||||
        newMessages.push(message);
 | 
			
		||||
      }
 | 
			
		||||
      setMessages(newMessages);
 | 
			
		||||
    },
 | 
			
		||||
    showAllContext: false,
 | 
			
		||||
    setShowAllContext: (showAllContext: boolean) => set(() => ({ showAllContext })),
 | 
			
		||||
    showChatSetting: false,
 | 
			
		||||
    setShowChatSetting: (showChatSetting: boolean) => set(() => ({ showChatSetting })),
 | 
			
		||||
    showList: false,
 | 
			
		||||
    setShowList: (showList: boolean) => set(() => ({ showList })),
 | 
			
		||||
    newChat: false,
 | 
			
		||||
    setNewChat: (newChat: boolean) => set(() => ({ newChat })),
 | 
			
		||||
    historyList: [],
 | 
			
		||||
    setHistoryList: (historyList: any[]) => set(() => ({ historyList })),
 | 
			
		||||
    getChatList: async () => {
 | 
			
		||||
      const res = await queryMark.getMarkList();
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        const len = res.data.list.length;
 | 
			
		||||
        // if (len === 0) {
 | 
			
		||||
        //   set(() => ({ newChat: true }));
 | 
			
		||||
        // }
 | 
			
		||||
        set(() => ({ historyList: res.data.list }));
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error(res.message || 'request chat list error');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    updateChat: async (data: any) => {
 | 
			
		||||
      const res = await queryMark.updateMark(data);
 | 
			
		||||
      console.log('update chat res', data);
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        toast.success('update success');
 | 
			
		||||
        get().setChatData(res.data);
 | 
			
		||||
        get().getChatList();
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error(res.message || 'error');
 | 
			
		||||
      }
 | 
			
		||||
      return res;
 | 
			
		||||
    },
 | 
			
		||||
    deleteChat: async (id: string) => {
 | 
			
		||||
      const res = await queryMark.deleteMark(id);
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        toast.success('delete success');
 | 
			
		||||
        get().getChatList();
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error(res.message || 'error');
 | 
			
		||||
      }
 | 
			
		||||
      return res;
 | 
			
		||||
    },
 | 
			
		||||
    chatData: {},
 | 
			
		||||
    setChatData: (chatData: any) => {
 | 
			
		||||
      const cache = new CacheStore({ dbName });
 | 
			
		||||
      cache.set(chatData.id, chatData);
 | 
			
		||||
      set(() => ({ chatData }));
 | 
			
		||||
    },
 | 
			
		||||
    showContext: false,
 | 
			
		||||
    setShowContext: (showContext: boolean) => set(() => ({ showContext })),
 | 
			
		||||
    contextMessage: {
 | 
			
		||||
      role: 'user',
 | 
			
		||||
      content: '',
 | 
			
		||||
      name: 'user',
 | 
			
		||||
    },
 | 
			
		||||
    setContextMessage: (contextMessage?: ChastHistoryMessage) => set(() => ({ contextMessage })),
 | 
			
		||||
    modelList: [],
 | 
			
		||||
    setModelList: (modelList: any[]) => set(() => ({ modelList })),
 | 
			
		||||
    getModelList: async (force?: boolean) => {
 | 
			
		||||
      const cache = new CacheStore({ dbName });
 | 
			
		||||
      const modelList = await cache.get('modelList');
 | 
			
		||||
      if (modelList && !force) {
 | 
			
		||||
        set(() => ({ modelList }));
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      // const res = await queryChat.getModelList();
 | 
			
		||||
      // if (res.code === 200) {
 | 
			
		||||
      //   set(() => ({ modelList: res.data.list }));
 | 
			
		||||
      //   cache.set('modelList', res.data.list);
 | 
			
		||||
      // } else {
 | 
			
		||||
      //   toast.error(res.message || 'error');
 | 
			
		||||
      // }
 | 
			
		||||
    },
 | 
			
		||||
    currentUserModel: undefined,
 | 
			
		||||
    setCurrentUserModel: (currentUserModel: { username: string; model: { group: string; provider: string; model: string } }) => {
 | 
			
		||||
      set(() => ({ currentUserModel }));
 | 
			
		||||
      const cache = new CacheStore({ dbName });
 | 
			
		||||
      cache.set('currentUserModel', currentUserModel);
 | 
			
		||||
    },
 | 
			
		||||
    chat: async () => {
 | 
			
		||||
      const { id, currentUserModel, messages } = get();
 | 
			
		||||
      if (!currentUserModel) {
 | 
			
		||||
        toast.error('请先设置当前用户模型');
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const { username, model } = currentUserModel;
 | 
			
		||||
      const { group, provider, model: modelName } = model;
 | 
			
		||||
      const chatOpts = {
 | 
			
		||||
        username,
 | 
			
		||||
        group,
 | 
			
		||||
        model: modelName,
 | 
			
		||||
      };
 | 
			
		||||
      set(() => ({ loading: true }));
 | 
			
		||||
      const loaded = toast.loading('loading...');
 | 
			
		||||
      const res = await queryChat.chat({ id, messages }, chatOpts);
 | 
			
		||||
      toast.dismiss(loaded);
 | 
			
		||||
      set(() => ({ loading: false }));
 | 
			
		||||
      console.log('chat res', res);
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        const data = res.data;
 | 
			
		||||
        const aiChatHistory = data.aiChatHistory;
 | 
			
		||||
        console.log('aiChatHistory ', data);
 | 
			
		||||
        if (aiChatHistory) {
 | 
			
		||||
          const { messages } = aiChatHistory;
 | 
			
		||||
          get().setChatData(aiChatHistory);
 | 
			
		||||
          get().setMessages(messages);
 | 
			
		||||
          console.log('aiChatHistory update', aiChatHistory);
 | 
			
		||||
        } else if (data.message) {
 | 
			
		||||
          const message = data.message;
 | 
			
		||||
          get().updateMessage(message);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error(res.message || 'error');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    clearConfigCache: async () => {
 | 
			
		||||
      const res = await queryChat.clearConfigCache();
 | 
			
		||||
      // 清理缓存
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        toast.success('clear config cache success');
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          get().getModelList(true);
 | 
			
		||||
        }, 4000);
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error(res.message || 'error');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    modelId: '',
 | 
			
		||||
    setModelId: (modelId: string) => set(() => ({ modelId })),
 | 
			
		||||
 | 
			
		||||
    showSetting: false,
 | 
			
		||||
    setShowSetting: (showSetting: boolean) => set(() => ({ showSetting })),
 | 
			
		||||
 | 
			
		||||
    showCopy: false,
 | 
			
		||||
    setShowCopy: (showCopy: boolean) => set(() => ({ showCopy })),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useChatStore = useStore as BoundStore<ChatStore>;
 | 
			
		||||
							
								
								
									
										10
									
								
								src/apps/ai-chat/store/type.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/apps/ai-chat/store/type.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
export type ChastHistoryMessage = {
 | 
			
		||||
  role: string;
 | 
			
		||||
  content: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
  createdAt?: number;
 | 
			
		||||
  updatedAt?: number;
 | 
			
		||||
  hide?: boolean;
 | 
			
		||||
  noUse?: boolean;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										18
									
								
								src/apps/ai-chat/utils/copy.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/apps/ai-chat/utils/copy.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
 | 
			
		||||
// 复制
 | 
			
		||||
export const copy = (text: string) => {
 | 
			
		||||
  navigator.clipboard.writeText(text);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const copyText = (text: string, showToast = true) => {
 | 
			
		||||
  copy(text);
 | 
			
		||||
  if (showToast) {
 | 
			
		||||
    toast.success('复制成功');
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const copyHtml = (html: string) => {
 | 
			
		||||
  copy(html);
 | 
			
		||||
  toast.success('复制成功');
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										12
									
								
								src/apps/ai-chat/utils/uuid.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/apps/ai-chat/utils/uuid.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
import { customAlphabet } from 'nanoid';
 | 
			
		||||
 | 
			
		||||
export const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
 | 
			
		||||
export const nanoid = customAlphabet(alphabet, 16);
 | 
			
		||||
 | 
			
		||||
export function uuid() {
 | 
			
		||||
  return nanoid();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const chatId = () => {
 | 
			
		||||
  return `chat-${nanoid()}`;
 | 
			
		||||
};
 | 
			
		||||
@@ -4,7 +4,7 @@ export const IconButton: typeof UiButton = (props) => {
 | 
			
		||||
  return <UiButton variant='ghost' size='icon' {...props} className={cn('h-8 w-8 cursor-pointer', props?.className)} />;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Button: typeof UiButton = (props) => {
 | 
			
		||||
export const Button = (props: Parameters<typeof UiButton>[0]) => {
 | 
			
		||||
  return <UiButton variant='ghost' {...props} className={cn('cursor-pointer', props?.className)} />;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ import { useEffect, useMemo, useState } from 'react';
 | 
			
		||||
type useConfirmOptions = {
 | 
			
		||||
  confrimProps: ConfirmProps;
 | 
			
		||||
};
 | 
			
		||||
type Fn = () => void;
 | 
			
		||||
type Fn = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
 | 
			
		||||
export const useConfirm = (opts?: useConfirmOptions) => {
 | 
			
		||||
  const [open, setOpen] = useState(false);
 | 
			
		||||
  type ConfirmOptions = {
 | 
			
		||||
@@ -78,19 +78,21 @@ export const Confirm = (props: ConfirmProps) => {
 | 
			
		||||
          {!props?.footer && (
 | 
			
		||||
            <>
 | 
			
		||||
              <AlertDialogCancel
 | 
			
		||||
                className='cursor-pointer'
 | 
			
		||||
                onClick={(e) => {
 | 
			
		||||
                  props?.onCancle?.();
 | 
			
		||||
                  props?.onCancle?.(e);
 | 
			
		||||
                  e.stopPropagation();
 | 
			
		||||
                }}>
 | 
			
		||||
                {props?.onCancelText ?? '取消'}
 | 
			
		||||
              </AlertDialogCancel>
 | 
			
		||||
              <AlertDialogAction
 | 
			
		||||
                className='cursor-pointer'
 | 
			
		||||
                onClick={(e) => {
 | 
			
		||||
                  props?.onOk?.();
 | 
			
		||||
                  props?.onOk?.(e);
 | 
			
		||||
                  e.stopPropagation();
 | 
			
		||||
                }}>
 | 
			
		||||
                {props?.onOkText ?? '确定'}
 | 
			
		||||
              </AlertDialogAction>{' '}
 | 
			
		||||
              </AlertDialogAction>
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </AlertDialogFooter>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										21
									
								
								src/components/a/divider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/a/divider.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
export const Divider = (props: { orientation?: 'horizontal' | 'vertical' }) => {
 | 
			
		||||
  const { orientation = 'horizontal' } = props;
 | 
			
		||||
 | 
			
		||||
  const dividerStyle: React.CSSProperties = {
 | 
			
		||||
    display: 'block',
 | 
			
		||||
    backgroundColor: '#e5e7eb', // 淡灰色分割线
 | 
			
		||||
    ...(orientation === 'horizontal'
 | 
			
		||||
      ? {
 | 
			
		||||
          width: '100%',
 | 
			
		||||
          height: '1px',
 | 
			
		||||
        }
 | 
			
		||||
      : {
 | 
			
		||||
          alignSelf: 'stretch',
 | 
			
		||||
          width: '1px',
 | 
			
		||||
          display: 'inline-block',
 | 
			
		||||
          margin: '2px 4px',
 | 
			
		||||
        }),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return <div style={dividerStyle} role='separator' aria-orientation={orientation} />;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										117
									
								
								src/components/a/drag-modal/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/components/a/drag-modal/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
			
		||||
import { useEffect, useRef, useState } from 'react';
 | 
			
		||||
import Draggable from 'react-draggable';
 | 
			
		||||
import { cn as clsxMerge } from '@/lib/utils';
 | 
			
		||||
import { Resizable } from 're-resizable';
 | 
			
		||||
import { X } from 'lucide-react';
 | 
			
		||||
 | 
			
		||||
type DragModalProps = {
 | 
			
		||||
  title?: React.ReactNode;
 | 
			
		||||
  content?: React.ReactNode;
 | 
			
		||||
  onClose?: () => void;
 | 
			
		||||
  containerClassName?: string;
 | 
			
		||||
  handleClassName?: string;
 | 
			
		||||
  contentClassName?: string;
 | 
			
		||||
  focus?: boolean;
 | 
			
		||||
  /**
 | 
			
		||||
   * 默认大小, 单位为px
 | 
			
		||||
   * width: defaultSize.width || 320
 | 
			
		||||
   * height: defaultSize.height || 400
 | 
			
		||||
   */
 | 
			
		||||
  defaultSize?: {
 | 
			
		||||
    width: number;
 | 
			
		||||
    height: number;
 | 
			
		||||
  };
 | 
			
		||||
  style?: React.CSSProperties;
 | 
			
		||||
};
 | 
			
		||||
export const DragModal = (props: DragModalProps) => {
 | 
			
		||||
  const dragRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Draggable
 | 
			
		||||
      nodeRef={dragRef as any}
 | 
			
		||||
      onStop={(e, data) => {
 | 
			
		||||
        // console.log(e, data);
 | 
			
		||||
      }}
 | 
			
		||||
      handle='.handle'
 | 
			
		||||
      grid={[1, 1]}
 | 
			
		||||
      scale={1}
 | 
			
		||||
      bounds='parent'
 | 
			
		||||
      defaultPosition={{
 | 
			
		||||
        x: 0,
 | 
			
		||||
        y: 0,
 | 
			
		||||
      }}>
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsxMerge('absolute top-0 left-0 bg-white rounded-md border border-gray-200 shadow-sm', props.focus ? 'z-30' : '', props.containerClassName)}
 | 
			
		||||
        ref={dragRef}
 | 
			
		||||
        style={props.style}>
 | 
			
		||||
        <div className={clsxMerge('handle cursor-move border-b border-gray-200 py-2 px-4', props.handleClassName)}>{props.title || 'Move'}</div>
 | 
			
		||||
        <Resizable
 | 
			
		||||
          className={clsxMerge('', props.contentClassName)}
 | 
			
		||||
          defaultSize={{
 | 
			
		||||
            width: props.defaultSize?.width || 600,
 | 
			
		||||
            height: props.defaultSize?.height || 400,
 | 
			
		||||
          }}
 | 
			
		||||
          onResizeStop={(e, direction, ref, d) => {
 | 
			
		||||
            // console.log(e, direction, ref, d);
 | 
			
		||||
          }}
 | 
			
		||||
          enable={{
 | 
			
		||||
            bottom: true,
 | 
			
		||||
            right: true,
 | 
			
		||||
            bottomRight: true,
 | 
			
		||||
          }}>
 | 
			
		||||
          {props.content}
 | 
			
		||||
        </Resizable>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Draggable>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type DragModalTitleProps = {
 | 
			
		||||
  title?: React.ReactNode;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  onClose?: () => void;
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
};
 | 
			
		||||
export const DragModalTitle = (props: DragModalTitleProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsxMerge('flex flex-row items-center justify-between', props.className)}
 | 
			
		||||
      onClick={(e) => {
 | 
			
		||||
        e.stopPropagation();
 | 
			
		||||
        props.onClick?.();
 | 
			
		||||
      }}>
 | 
			
		||||
      <div className='text-sm font-medium text-gray-700'>
 | 
			
		||||
        {props.title}
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        className='text-gray-500 cursor-pointer p-2 hover:bg-gray-100 rounded-md'
 | 
			
		||||
        onClick={(e) => {
 | 
			
		||||
          e.stopPropagation();
 | 
			
		||||
          props.onClose?.();
 | 
			
		||||
        }}>
 | 
			
		||||
        <X className='w-4 h-4 ' />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getComputedHeight = () => {
 | 
			
		||||
  const height = window.innerHeight;
 | 
			
		||||
  const width = window.innerWidth;
 | 
			
		||||
  return { height, width };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useComputedHeight = () => {
 | 
			
		||||
  const [computedHeight, setComputedHeight] = useState({
 | 
			
		||||
    height: 0,
 | 
			
		||||
    width: 0,
 | 
			
		||||
  });
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const height = window.innerHeight;
 | 
			
		||||
    const width = window.innerWidth;
 | 
			
		||||
    setComputedHeight({ height, width });
 | 
			
		||||
  }, []);
 | 
			
		||||
  return computedHeight;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										6
									
								
								src/components/a/input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/components/a/input.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
import { Input as UIInput } from '@/components/ui/input';
 | 
			
		||||
 | 
			
		||||
export type InputProps = { label?: string } & React.ComponentProps<'input'>;
 | 
			
		||||
export const Input = (props: InputProps) => {
 | 
			
		||||
  return <UIInput {...props} />;
 | 
			
		||||
};
 | 
			
		||||
@@ -5,10 +5,12 @@ type Option = {
 | 
			
		||||
  label?: string;
 | 
			
		||||
};
 | 
			
		||||
type SelectProps = {
 | 
			
		||||
  className?: string;
 | 
			
		||||
  options?: Option[];
 | 
			
		||||
  value?: string;
 | 
			
		||||
  placeholder?: string;
 | 
			
		||||
  onChange?: (value: string) => any;
 | 
			
		||||
  onChange?: (value: any) => any;
 | 
			
		||||
  size?: 'small' | 'medium' | 'large';
 | 
			
		||||
};
 | 
			
		||||
export const Select = (props: SelectProps) => {
 | 
			
		||||
  const options = props.options || [];
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
export const Tooltip = (props: { children?: React.ReactNode; title?: string }) => {
 | 
			
		||||
export const Tooltip = (props: { children?: React.ReactNode; title?: React.ReactNode }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipProvider>
 | 
			
		||||
      <UITooltip>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										115
									
								
								src/components/ai/RenderMarkdown.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/components/ai/RenderMarkdown.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,115 @@
 | 
			
		||||
import { throttle } from 'lodash-es';
 | 
			
		||||
import { Marked } from 'marked';
 | 
			
		||||
import 'highlight.js/styles/github.css'; // 你可以选择其他样式
 | 
			
		||||
import { useRef, useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
import { markedHighlight } from 'marked-highlight';
 | 
			
		||||
import hljs from 'highlight.js';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
 | 
			
		||||
const marked = new Marked(
 | 
			
		||||
  markedHighlight({
 | 
			
		||||
    emptyLangClass: 'hljs',
 | 
			
		||||
    langPrefix: 'hljs language-',
 | 
			
		||||
    highlight(code, lang, info) {
 | 
			
		||||
      const language = hljs.getLanguage(lang) ? lang : 'plaintext';
 | 
			
		||||
      return hljs.highlight(code, { language }).value;
 | 
			
		||||
    },
 | 
			
		||||
  }),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type ResponseTextProps = {
 | 
			
		||||
  response: Response;
 | 
			
		||||
  onFinish?: (text: string) => void;
 | 
			
		||||
  onChange?: (text: string) => void;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
};
 | 
			
		||||
export const ResponseText = (props: ResponseTextProps) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const render = async () => {
 | 
			
		||||
    const response = props.response;
 | 
			
		||||
    if (!response) return;
 | 
			
		||||
    const msg = ref.current!;
 | 
			
		||||
    if (!msg) {
 | 
			
		||||
      console.log('msg is null');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    await new Promise((resolve) => setTimeout(resolve, 100));
 | 
			
		||||
    const reader = response.body?.getReader();
 | 
			
		||||
    const decoder = new TextDecoder('utf-8');
 | 
			
		||||
    let done = false;
 | 
			
		||||
 | 
			
		||||
    while (!done) {
 | 
			
		||||
      const { value, done: streamDone } = await reader!.read();
 | 
			
		||||
      done = streamDone;
 | 
			
		||||
 | 
			
		||||
      if (value) {
 | 
			
		||||
        const chunk = decoder.decode(value, { stream: true });
 | 
			
		||||
        // 更新状态,实时刷新 UI
 | 
			
		||||
        msg.innerHTML += chunk;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (done) {
 | 
			
		||||
      props.onFinish && props.onFinish(msg.innerHTML);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    render();
 | 
			
		||||
  }, []);
 | 
			
		||||
  return <div id={props.id} className={clsx('response markdown-body', props.className)} ref={ref}></div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ResponseMarkdown = (props: ResponseTextProps) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  let content = '';
 | 
			
		||||
  const render = async () => {
 | 
			
		||||
    const response = props.response;
 | 
			
		||||
    if (!response) return;
 | 
			
		||||
    const msg = ref.current!;
 | 
			
		||||
    if (!msg) {
 | 
			
		||||
      console.log('msg is null');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    await new Promise((resolve) => setTimeout(resolve, 100));
 | 
			
		||||
    const reader = response.body?.getReader();
 | 
			
		||||
    const decoder = new TextDecoder('utf-8');
 | 
			
		||||
    let done = false;
 | 
			
		||||
 | 
			
		||||
    while (!done) {
 | 
			
		||||
      const { value, done: streamDone } = await reader!.read();
 | 
			
		||||
      done = streamDone;
 | 
			
		||||
 | 
			
		||||
      if (value) {
 | 
			
		||||
        const chunk = decoder.decode(value, { stream: true });
 | 
			
		||||
        content = content + chunk;
 | 
			
		||||
        renderThrottle(content);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (done) {
 | 
			
		||||
      props.onFinish && props.onFinish(msg.innerHTML);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  const renderThrottle = throttle(async (markdown: string) => {
 | 
			
		||||
    const msg = ref.current!;
 | 
			
		||||
    msg.innerHTML = await marked.parse(markdown);
 | 
			
		||||
    props.onChange?.(msg.innerHTML);
 | 
			
		||||
  }, 100);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    render();
 | 
			
		||||
  }, [props.response]);
 | 
			
		||||
  return <div id={props.id} className={clsx('response markdown-body', props.className)} ref={ref}></div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Markdown = (props: { className?: string; markdown: string; id?: string }) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const parse = async () => {
 | 
			
		||||
    const md = await marked.parse(props.markdown);
 | 
			
		||||
    const msg = ref.current!;
 | 
			
		||||
    msg.innerHTML = md;
 | 
			
		||||
  };
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    parse();
 | 
			
		||||
  }, []);
 | 
			
		||||
  return <div id={props.id} ref={ref} className={clsx('markdown-body', props.className)}></div>;
 | 
			
		||||
};
 | 
			
		||||
@@ -6,6 +6,16 @@
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset='UTF-8' />
 | 
			
		||||
    <title>AI Pages</title>
 | 
			
		||||
    <style>
 | 
			
		||||
      html,
 | 
			
		||||
      body {
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        padding: 0;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
      }
 | 
			
		||||
    </style>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
    <slot />
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# srvice
 | 
			
		||||
# service
 | 
			
		||||
 | 
			
		||||
关于服务器构思分享。
 | 
			
		||||
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import { Query } from '@kevisual/query';
 | 
			
		||||
import { Query, ClientQuery } from '@kevisual/query/query';
 | 
			
		||||
 | 
			
		||||
export const query = new Query();
 | 
			
		||||
 | 
			
		||||
export const clientQuery = new Query({ url: '/client/router' });
 | 
			
		||||
export const clientQuery = new ClientQuery();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,9 @@
 | 
			
		||||
---
 | 
			
		||||
import '../styles/global.css';
 | 
			
		||||
import Blank from '@/components/html/blank.astro';
 | 
			
		||||
import { App as AIChat } from '@/apps/ai-chat/index.tsx';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<Blank>
 | 
			
		||||
  <div>sdf</div>
 | 
			
		||||
  <AIChat client:only />
 | 
			
		||||
</Blank>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								src/query/kevisual.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/query/kevisual.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://kevisual.xiongxiao.me/root/ai/kevisual/tools/kevisual-sync/schema.json?v=2",
 | 
			
		||||
  "metadata": {
 | 
			
		||||
    "share": "public"
 | 
			
		||||
  },
 | 
			
		||||
  "checkDir": {
 | 
			
		||||
    "src/query": {
 | 
			
		||||
      "url": "https://kevisual.xiongxiao.me/root/ai/code/registry/query",
 | 
			
		||||
      "enabled": true
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "syncDirectory": [
 | 
			
		||||
    {
 | 
			
		||||
      "files": [
 | 
			
		||||
        "src/query/**/*"
 | 
			
		||||
      ],
 | 
			
		||||
      "ignore": [],
 | 
			
		||||
      "registry": "https://kevisual.xiongxiao.me/root/ai/code/registry",
 | 
			
		||||
      "replace": {
 | 
			
		||||
        "src/": ""
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "sync": {}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								src/query/query-ai/defines/ai.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/query/query-ai/defines/ai.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import { QueryUtil } from '@/query/index.ts';
 | 
			
		||||
 | 
			
		||||
type Message = {
 | 
			
		||||
  role?: 'user' | 'assistant' | 'system' | 'tool';
 | 
			
		||||
  content?: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
};
 | 
			
		||||
export type PostChat = {
 | 
			
		||||
  messages?: Message[];
 | 
			
		||||
  model?: string;
 | 
			
		||||
  group?: string;
 | 
			
		||||
  user?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const appDefine = QueryUtil.create({
 | 
			
		||||
  chat: {
 | 
			
		||||
    path: 'ai',
 | 
			
		||||
    key: 'chat',
 | 
			
		||||
    description: '与 AI 进行对话, 调用 GPT 的AI 服务,生成结果,并返回。',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										25
									
								
								src/query/query-ai/query-ai.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/query/query-ai/query-ai.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
import { appDefine } from './defines/ai.ts';
 | 
			
		||||
import { PostChat } from './defines/ai.ts';
 | 
			
		||||
 | 
			
		||||
import { BaseQuery, DataOpts, Query } from '@kevisual/query/query';
 | 
			
		||||
 | 
			
		||||
export { appDefine };
 | 
			
		||||
 | 
			
		||||
export class QueryApp<T extends Query = Query> extends BaseQuery<T, typeof appDefine> {
 | 
			
		||||
  constructor(opts?: { query: T }) {
 | 
			
		||||
    super({
 | 
			
		||||
      ...opts,
 | 
			
		||||
      query: opts?.query!,
 | 
			
		||||
      queryDefine: appDefine,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 与 AI 进行对话, 调用 GPT 的AI 服务,生成结果,并返回。
 | 
			
		||||
   * @param data
 | 
			
		||||
   * @param opts
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  postChat(data: PostChat, opts?: DataOpts) {
 | 
			
		||||
    return this.chain('chat').post(data, opts);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										204
									
								
								src/query/query-login/login-cache.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								src/query/query-login/login-cache.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,204 @@
 | 
			
		||||
export interface Cache {
 | 
			
		||||
  /**
 | 
			
		||||
   * @update 获取缓存
 | 
			
		||||
   */
 | 
			
		||||
  get(key: string): Promise<any>;
 | 
			
		||||
  /**
 | 
			
		||||
   * @update 设置缓存
 | 
			
		||||
   */
 | 
			
		||||
  set(key: string, value: any): Promise<any>;
 | 
			
		||||
  /**
 | 
			
		||||
   * @update 删除缓存
 | 
			
		||||
   */
 | 
			
		||||
  del(): Promise<void>;
 | 
			
		||||
  /**
 | 
			
		||||
   * 初始化
 | 
			
		||||
   */
 | 
			
		||||
  init?: () => Promise<any>;
 | 
			
		||||
}
 | 
			
		||||
type User = {
 | 
			
		||||
  avatar?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
  needChangePassword?: boolean;
 | 
			
		||||
  orgs?: string[];
 | 
			
		||||
  type?: string;
 | 
			
		||||
  username?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type CacheLoginUser = {
 | 
			
		||||
  user?: User;
 | 
			
		||||
  id?: string;
 | 
			
		||||
  accessToken?: string;
 | 
			
		||||
  refreshToken?: string;
 | 
			
		||||
};
 | 
			
		||||
type CacheLogin = {
 | 
			
		||||
  loginUsers: CacheLoginUser[];
 | 
			
		||||
} & CacheLoginUser;
 | 
			
		||||
 | 
			
		||||
export type CacheStore<T = Cache> = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * 缓存数据
 | 
			
		||||
   * @important 需要先调用init
 | 
			
		||||
   */
 | 
			
		||||
  cacheData: CacheLogin;
 | 
			
		||||
  /**
 | 
			
		||||
   * 实际操作的cache, 需要先调用init
 | 
			
		||||
   */
 | 
			
		||||
  cache: T;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 设置当前用户
 | 
			
		||||
   */
 | 
			
		||||
  setLoginUser(user: CacheLoginUser): Promise<void>;
 | 
			
		||||
  /**
 | 
			
		||||
   * 获取当前用户
 | 
			
		||||
   */
 | 
			
		||||
  getCurrentUser(): Promise<User>;
 | 
			
		||||
  /**
 | 
			
		||||
   * 获取当前用户列表
 | 
			
		||||
   */
 | 
			
		||||
  getCurrentUserList(): Promise<CacheLoginUser[]>;
 | 
			
		||||
  /**
 | 
			
		||||
   * 获取缓存的refreshToken
 | 
			
		||||
   */
 | 
			
		||||
  getRefreshToken(): Promise<string>;
 | 
			
		||||
  /**
 | 
			
		||||
   * 获取缓存的accessToken
 | 
			
		||||
   */
 | 
			
		||||
  getAccessToken(): Promise<string>;
 | 
			
		||||
  /**
 | 
			
		||||
   * 清除当前用户
 | 
			
		||||
   */
 | 
			
		||||
  clearCurrentUser(): Promise<void>;
 | 
			
		||||
  /**
 | 
			
		||||
   * 清除所有用户
 | 
			
		||||
   */
 | 
			
		||||
  clearAll(): Promise<void>;
 | 
			
		||||
 | 
			
		||||
  getValue(): Promise<CacheLogin>;
 | 
			
		||||
  setValue(value: CacheLogin): Promise<CacheLogin>;
 | 
			
		||||
  delValue(): Promise<void>;
 | 
			
		||||
  init(): Promise<any>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type LoginCacheStoreOpts = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  cache: Cache;
 | 
			
		||||
};
 | 
			
		||||
export class LoginCacheStore implements CacheStore<any> {
 | 
			
		||||
  cache: Cache;
 | 
			
		||||
  name: string;
 | 
			
		||||
  cacheData: CacheLogin;
 | 
			
		||||
  constructor(opts: LoginCacheStoreOpts) {
 | 
			
		||||
    if (!opts.cache) {
 | 
			
		||||
      throw new Error('cache is required');
 | 
			
		||||
    }
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    this.cache = opts.cache;
 | 
			
		||||
    this.cacheData = {
 | 
			
		||||
      loginUsers: [],
 | 
			
		||||
      user: undefined,
 | 
			
		||||
      id: undefined,
 | 
			
		||||
      accessToken: undefined,
 | 
			
		||||
      refreshToken: undefined,
 | 
			
		||||
    };
 | 
			
		||||
    this.name = opts.name;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 设置缓存
 | 
			
		||||
   * @param key
 | 
			
		||||
   * @param value
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async setValue(value: CacheLogin) {
 | 
			
		||||
    await this.cache.set(this.name, value);
 | 
			
		||||
    this.cacheData = value;
 | 
			
		||||
    return value;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 删除缓存
 | 
			
		||||
   */
 | 
			
		||||
  async delValue() {
 | 
			
		||||
    await this.cache.del();
 | 
			
		||||
  }
 | 
			
		||||
  getValue(): Promise<CacheLogin> {
 | 
			
		||||
    return this.cache.get(this.name);
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 初始化,设置默认值
 | 
			
		||||
   */
 | 
			
		||||
  async init() {
 | 
			
		||||
    const defaultData = {
 | 
			
		||||
      loginUsers: [],
 | 
			
		||||
      user: null,
 | 
			
		||||
      id: null,
 | 
			
		||||
      accessToken: null,
 | 
			
		||||
      refreshToken: null,
 | 
			
		||||
    };
 | 
			
		||||
    if (this.cache.init) {
 | 
			
		||||
      try {
 | 
			
		||||
        const cacheData = await this.cache.init();
 | 
			
		||||
        this.cacheData = cacheData || defaultData;
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.log('cacheInit error', error);
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      this.cacheData = (await this.getValue()) || defaultData;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 设置当前用户
 | 
			
		||||
   * @param user
 | 
			
		||||
   */
 | 
			
		||||
  async setLoginUser(user: CacheLoginUser) {
 | 
			
		||||
    const has = this.cacheData.loginUsers.find((u) => u.id === user.id);
 | 
			
		||||
    if (has) {
 | 
			
		||||
      this.cacheData.loginUsers = this.cacheData?.loginUsers?.filter((u) => u?.id && u.id !== user.id);
 | 
			
		||||
    }
 | 
			
		||||
    this.cacheData.loginUsers.push(user);
 | 
			
		||||
    this.cacheData.user = user.user;
 | 
			
		||||
    this.cacheData.id = user.id;
 | 
			
		||||
    this.cacheData.accessToken = user.accessToken;
 | 
			
		||||
    this.cacheData.refreshToken = user.refreshToken;
 | 
			
		||||
    await this.setValue(this.cacheData);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getCurrentUser(): Promise<CacheLoginUser> {
 | 
			
		||||
    const cacheData = this.cacheData;
 | 
			
		||||
    return Promise.resolve(cacheData.user!);
 | 
			
		||||
  }
 | 
			
		||||
  getCurrentUserList(): Promise<CacheLoginUser[]> {
 | 
			
		||||
    return Promise.resolve(this.cacheData.loginUsers.filter((u) => u?.id));
 | 
			
		||||
  }
 | 
			
		||||
  getRefreshToken(): Promise<string> {
 | 
			
		||||
    const cacheData = this.cacheData;
 | 
			
		||||
    return Promise.resolve(cacheData.refreshToken || '');
 | 
			
		||||
  }
 | 
			
		||||
  getAccessToken(): Promise<string> {
 | 
			
		||||
    const cacheData = this.cacheData;
 | 
			
		||||
    return Promise.resolve(cacheData.accessToken || '');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async clearCurrentUser() {
 | 
			
		||||
    const user = await this.getCurrentUser();
 | 
			
		||||
    const has = this.cacheData.loginUsers.find((u) => u.id === user.id);
 | 
			
		||||
    if (has) {
 | 
			
		||||
      this.cacheData.loginUsers = this.cacheData?.loginUsers?.filter((u) => u?.id && u.id !== user.id);
 | 
			
		||||
    }
 | 
			
		||||
    this.cacheData.user = undefined;
 | 
			
		||||
    this.cacheData.id = undefined;
 | 
			
		||||
    this.cacheData.accessToken = undefined;
 | 
			
		||||
    this.cacheData.refreshToken = undefined;
 | 
			
		||||
    await this.setValue(this.cacheData);
 | 
			
		||||
  }
 | 
			
		||||
  async clearAll() {
 | 
			
		||||
    this.cacheData.loginUsers = [];
 | 
			
		||||
    this.cacheData.user = undefined;
 | 
			
		||||
    this.cacheData.id = undefined;
 | 
			
		||||
    this.cacheData.accessToken = undefined;
 | 
			
		||||
    this.cacheData.refreshToken = undefined;
 | 
			
		||||
    await this.setValue(this.cacheData);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										132
									
								
								src/query/query-login/login-node-cache.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/query/query-login/login-node-cache.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,132 @@
 | 
			
		||||
import { Cache } from './login-cache.ts';
 | 
			
		||||
import { homedir } from 'node:os';
 | 
			
		||||
import { join, dirname } from 'node:path';
 | 
			
		||||
import fs from 'node:fs';
 | 
			
		||||
import { readFileSync, writeFileSync, accessSync } from 'node:fs';
 | 
			
		||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
 | 
			
		||||
export const fileExists = async (
 | 
			
		||||
  filePath: string,
 | 
			
		||||
  { createIfNotExists = true, isFile = true, isDir = false }: { createIfNotExists?: boolean; isFile?: boolean; isDir?: boolean } = {},
 | 
			
		||||
) => {
 | 
			
		||||
  try {
 | 
			
		||||
    accessSync(filePath, fs.constants.F_OK);
 | 
			
		||||
    return true;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    if (createIfNotExists && isDir) {
 | 
			
		||||
      await mkdir(filePath, { recursive: true });
 | 
			
		||||
      return true;
 | 
			
		||||
    } else if (createIfNotExists && isFile) {
 | 
			
		||||
      await mkdir(dirname(filePath), { recursive: true });
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
export const readConfigFile = (filePath: string) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const data = readFileSync(filePath, 'utf-8');
 | 
			
		||||
    const jsonData = JSON.parse(data);
 | 
			
		||||
    return jsonData;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    return {};
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
export const writeConfigFile = (filePath: string, data: any) => {
 | 
			
		||||
  writeFileSync(filePath, JSON.stringify(data, null, 2));
 | 
			
		||||
};
 | 
			
		||||
export const getHostName = () => {
 | 
			
		||||
  const configDir = join(homedir(), '.config', 'envision');
 | 
			
		||||
  const configFile = join(configDir, 'config.json');
 | 
			
		||||
  const config = readConfigFile(configFile);
 | 
			
		||||
  const baseURL = config.baseURL || 'https://kevisual.cn';
 | 
			
		||||
  const hostname = new URL(baseURL).hostname;
 | 
			
		||||
  return hostname;
 | 
			
		||||
};
 | 
			
		||||
export class StorageNode implements Storage {
 | 
			
		||||
  cacheData: any;
 | 
			
		||||
  filePath: string;
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.cacheData = {};
 | 
			
		||||
    const configDir = join(homedir(), '.config', 'envision');
 | 
			
		||||
    const hostname = getHostName();
 | 
			
		||||
    this.filePath = join(configDir, 'config', `${hostname}-storage.json`);
 | 
			
		||||
    fileExists(this.filePath, { isFile: true });
 | 
			
		||||
  }
 | 
			
		||||
  async loadCache() {
 | 
			
		||||
    const filePath = this.filePath;
 | 
			
		||||
    try {
 | 
			
		||||
      const data = await readConfigFile(filePath);
 | 
			
		||||
      this.cacheData = data;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      this.cacheData = {};
 | 
			
		||||
      await writeFile(filePath, JSON.stringify(this.cacheData, null, 2));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  get length() {
 | 
			
		||||
    return Object.keys(this.cacheData).length;
 | 
			
		||||
  }
 | 
			
		||||
  getItem(key: string) {
 | 
			
		||||
    return this.cacheData[key];
 | 
			
		||||
  }
 | 
			
		||||
  setItem(key: string, value: any) {
 | 
			
		||||
    this.cacheData[key] = value;
 | 
			
		||||
    writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2));
 | 
			
		||||
  }
 | 
			
		||||
  removeItem(key: string) {
 | 
			
		||||
    delete this.cacheData[key];
 | 
			
		||||
    writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2));
 | 
			
		||||
  }
 | 
			
		||||
  clear() {
 | 
			
		||||
    this.cacheData = {};
 | 
			
		||||
    writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2));
 | 
			
		||||
  }
 | 
			
		||||
  key(index: number) {
 | 
			
		||||
    return Object.keys(this.cacheData)[index];
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
export class LoginNodeCache implements Cache {
 | 
			
		||||
  filepath: string;
 | 
			
		||||
 | 
			
		||||
  constructor(filepath?: string) {
 | 
			
		||||
    this.filepath = filepath || join(homedir(), '.config', 'envision', 'config', `${getHostName()}-login.json`);
 | 
			
		||||
    fileExists(this.filepath, { isFile: true });
 | 
			
		||||
  }
 | 
			
		||||
  async get(_key: string) {
 | 
			
		||||
    try {
 | 
			
		||||
      const filePath = this.filepath;
 | 
			
		||||
      const data = readConfigFile(filePath);
 | 
			
		||||
      return data;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log('get error', error);
 | 
			
		||||
      return {};
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  async set(_key: string, value: any) {
 | 
			
		||||
    try {
 | 
			
		||||
      const data = readConfigFile(this.filepath);
 | 
			
		||||
      const newData = { ...data, ...value };
 | 
			
		||||
      writeConfigFile(this.filepath, newData);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log('set error', error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  async del() {
 | 
			
		||||
    await unlink(this.filepath);
 | 
			
		||||
  }
 | 
			
		||||
  async loadCache(filePath: string) {
 | 
			
		||||
    try {
 | 
			
		||||
      const data = await readFile(filePath, 'utf-8');
 | 
			
		||||
      const jsonData = JSON.parse(data);
 | 
			
		||||
      return jsonData;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      // console.log('loadCache error', error);
 | 
			
		||||
      console.log('create new cache file:', filePath);
 | 
			
		||||
      const defaultData = { loginUsers: [] };
 | 
			
		||||
      writeConfigFile(filePath, defaultData);
 | 
			
		||||
      return defaultData;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  async init() {
 | 
			
		||||
    return await this.loadCache(this.filepath);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										12
									
								
								src/query/query-login/query-login-browser.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/query/query-login/query-login-browser.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
import { QueryLogin, QueryLoginOpts } from './query-login.ts';
 | 
			
		||||
import { MyCache } from '@kevisual/cache';
 | 
			
		||||
type QueryLoginNodeOptsWithoutCache = Omit<QueryLoginOpts, 'cache'>;
 | 
			
		||||
 | 
			
		||||
export class QueryLoginBrowser extends QueryLogin {
 | 
			
		||||
  constructor(opts: QueryLoginNodeOptsWithoutCache) {
 | 
			
		||||
    super({
 | 
			
		||||
      ...opts,
 | 
			
		||||
      cache: new MyCache('login'),
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/query/query-login/query-login-node.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/query/query-login/query-login-node.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
import { QueryLogin, QueryLoginOpts } from './query-login.ts';
 | 
			
		||||
import { LoginNodeCache, StorageNode } from './login-node-cache.ts';
 | 
			
		||||
type QueryLoginNodeOptsWithoutCache = Omit<QueryLoginOpts, 'cache'>;
 | 
			
		||||
export const storage = new StorageNode();
 | 
			
		||||
await storage.loadCache();
 | 
			
		||||
export class QueryLoginNode extends QueryLogin {
 | 
			
		||||
  constructor(opts: QueryLoginNodeOptsWithoutCache) {
 | 
			
		||||
    super({
 | 
			
		||||
      ...opts,
 | 
			
		||||
      storage,
 | 
			
		||||
      cache: new LoginNodeCache(),
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										419
									
								
								src/query/query-login/query-login.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										419
									
								
								src/query/query-login/query-login.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,419 @@
 | 
			
		||||
import { Query, BaseQuery } from '@kevisual/query';
 | 
			
		||||
import type { Result, DataOpts } from '@kevisual/query/query';
 | 
			
		||||
import { setBaseResponse } from '@kevisual/query/query';
 | 
			
		||||
import { LoginCacheStore, CacheStore } from './login-cache.ts';
 | 
			
		||||
import { Cache } from './login-cache.ts';
 | 
			
		||||
 | 
			
		||||
export type QueryLoginOpts = {
 | 
			
		||||
  query?: Query;
 | 
			
		||||
  isBrowser?: boolean;
 | 
			
		||||
  onLoad?: () => void;
 | 
			
		||||
  storage?: Storage;
 | 
			
		||||
  cache: Cache;
 | 
			
		||||
};
 | 
			
		||||
export type QueryLoginData = {
 | 
			
		||||
  username?: string;
 | 
			
		||||
  password: string;
 | 
			
		||||
  email?: string;
 | 
			
		||||
};
 | 
			
		||||
export type QueryLoginResult = {
 | 
			
		||||
  accessToken: string;
 | 
			
		||||
  refreshToken: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class QueryLogin extends BaseQuery {
 | 
			
		||||
  /**
 | 
			
		||||
   * query login cache, 非实际操作, 一个cache的包裹模块
 | 
			
		||||
   */
 | 
			
		||||
  cacheStore: CacheStore;
 | 
			
		||||
  isBrowser: boolean;
 | 
			
		||||
  load?: boolean;
 | 
			
		||||
  storage: Storage;
 | 
			
		||||
  onLoad?: () => void;
 | 
			
		||||
 | 
			
		||||
  constructor(opts?: QueryLoginOpts) {
 | 
			
		||||
    super({
 | 
			
		||||
      query: opts?.query || new Query(),
 | 
			
		||||
    });
 | 
			
		||||
    this.cacheStore = new LoginCacheStore({ name: 'login', cache: opts.cache });
 | 
			
		||||
    this.isBrowser = opts?.isBrowser ?? true;
 | 
			
		||||
    this.init();
 | 
			
		||||
    this.onLoad = opts?.onLoad;
 | 
			
		||||
    this.storage = opts?.storage || localStorage;
 | 
			
		||||
  }
 | 
			
		||||
  setQuery(query: Query) {
 | 
			
		||||
    this.query = query;
 | 
			
		||||
  }
 | 
			
		||||
  private async init() {
 | 
			
		||||
    await this.cacheStore.init();
 | 
			
		||||
    this.load = true;
 | 
			
		||||
    this.onLoad?.();
 | 
			
		||||
  }
 | 
			
		||||
  async post<T = any>(data: any, opts?: DataOpts) {
 | 
			
		||||
    try {
 | 
			
		||||
      return this.query.post<T>({ path: 'user', ...data }, opts);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log('error', error);
 | 
			
		||||
      return {
 | 
			
		||||
        code: 400,
 | 
			
		||||
      } as any;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 登录,
 | 
			
		||||
   * @param data
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async login(data: QueryLoginData) {
 | 
			
		||||
    const res = await this.post<QueryLoginResult>({ key: 'login', ...data });
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      const { accessToken, refreshToken } = res?.data || {};
 | 
			
		||||
      this.storage.setItem('token', accessToken || '');
 | 
			
		||||
      await this.beforeSetLoginUser({ accessToken, refreshToken });
 | 
			
		||||
    }
 | 
			
		||||
    return res;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 手机号登录
 | 
			
		||||
   * @param data
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async loginByCode(data: { phone: string; code: string }) {
 | 
			
		||||
    const res = await this.post<QueryLoginResult>({ path: 'sms', key: 'login', data });
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      const { accessToken, refreshToken } = res?.data || {};
 | 
			
		||||
      this.storage.setItem('token', accessToken || '');
 | 
			
		||||
      await this.beforeSetLoginUser({ accessToken, refreshToken });
 | 
			
		||||
    }
 | 
			
		||||
    return res;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 设置token
 | 
			
		||||
   * @param token
 | 
			
		||||
   */
 | 
			
		||||
  async setLoginToken(token: { accessToken: string; refreshToken: string }) {
 | 
			
		||||
    const { accessToken, refreshToken } = token;
 | 
			
		||||
    this.storage.setItem('token', accessToken || '');
 | 
			
		||||
    await this.beforeSetLoginUser({ accessToken, refreshToken });
 | 
			
		||||
  }
 | 
			
		||||
  async loginByWechat(data: { code: string }) {
 | 
			
		||||
    const res = await this.post<QueryLoginResult>({ path: 'wx', key: 'open-login', code: data.code });
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      const { accessToken, refreshToken } = res?.data || {};
 | 
			
		||||
      this.storage.setItem('token', accessToken || '');
 | 
			
		||||
      await this.beforeSetLoginUser({ accessToken, refreshToken });
 | 
			
		||||
    }
 | 
			
		||||
    return res;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 检测微信登录,登陆成功后,调用onSuccess,否则调用onError
 | 
			
		||||
   * @param param0
 | 
			
		||||
   */
 | 
			
		||||
  async checkWechat({ onSuccess, onError }: { onSuccess?: (res: QueryLoginResult) => void; onError?: (res: any) => void }) {
 | 
			
		||||
    const url = new URL(window.location.href);
 | 
			
		||||
    const code = url.searchParams.get('code');
 | 
			
		||||
    const state = url.searchParams.get('state');
 | 
			
		||||
    if (code && state) {
 | 
			
		||||
      const res = await this.loginByWechat({ code });
 | 
			
		||||
      if (res.code === 200) {
 | 
			
		||||
        onSuccess?.(res.data);
 | 
			
		||||
      } else {
 | 
			
		||||
        onError?.(res);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 登陆成功,需要获取用户信息进行缓存
 | 
			
		||||
   * @param param0
 | 
			
		||||
   */
 | 
			
		||||
  async beforeSetLoginUser({ accessToken, refreshToken, check401 }: { accessToken?: string; refreshToken?: string; check401?: boolean }) {
 | 
			
		||||
    if (accessToken && refreshToken) {
 | 
			
		||||
      const resUser = await this.getMe(accessToken, check401);
 | 
			
		||||
      if (resUser.code === 200) {
 | 
			
		||||
        const user = resUser.data;
 | 
			
		||||
        if (user) {
 | 
			
		||||
          this.cacheStore.setLoginUser({
 | 
			
		||||
            user,
 | 
			
		||||
            id: user.id,
 | 
			
		||||
            accessToken,
 | 
			
		||||
            refreshToken,
 | 
			
		||||
          });
 | 
			
		||||
        } else {
 | 
			
		||||
          console.error('登录失败');
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 刷新token
 | 
			
		||||
   * @param refreshToken
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async queryRefreshToken(refreshToken?: string) {
 | 
			
		||||
    const _refreshToken = refreshToken || this.cacheStore.getRefreshToken();
 | 
			
		||||
    let data = { refreshToken: _refreshToken };
 | 
			
		||||
    if (!_refreshToken) {
 | 
			
		||||
      await this.cacheStore.clearCurrentUser();
 | 
			
		||||
      return {
 | 
			
		||||
        code: 401,
 | 
			
		||||
        message: '请先登录',
 | 
			
		||||
        data: {} as any,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    return this.post(
 | 
			
		||||
      { key: 'refreshToken', data },
 | 
			
		||||
      {
 | 
			
		||||
        afterResponse: async (response, ctx) => {
 | 
			
		||||
          setBaseResponse(response);
 | 
			
		||||
          return response as any;
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 检查401错误,并刷新token, 如果refreshToken存在,则刷新token, 否则返回401
 | 
			
		||||
   * 拦截请求,请使用run401Action, 不要直接使用 afterCheck401ToRefreshToken
 | 
			
		||||
   * @param response
 | 
			
		||||
   * @param ctx
 | 
			
		||||
   * @param refetch
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async afterCheck401ToRefreshToken(response: Result, ctx?: { req?: any; res?: any; fetch?: any }, refetch?: boolean) {
 | 
			
		||||
    const that = this;
 | 
			
		||||
    if (response?.code === 401) {
 | 
			
		||||
      const hasRefreshToken = await that.cacheStore.getRefreshToken();
 | 
			
		||||
      if (hasRefreshToken) {
 | 
			
		||||
        const res = await that.queryRefreshToken(hasRefreshToken);
 | 
			
		||||
        if (res.code === 200) {
 | 
			
		||||
          const { accessToken, refreshToken } = res?.data || {};
 | 
			
		||||
          that.storage.setItem('token', accessToken || '');
 | 
			
		||||
          await that.beforeSetLoginUser({ accessToken, refreshToken, check401: false });
 | 
			
		||||
          if (refetch && ctx && ctx.req && ctx.req.url && ctx.fetch) {
 | 
			
		||||
            await new Promise((resolve) => setTimeout(resolve, 1500));
 | 
			
		||||
            const url = ctx.req?.url;
 | 
			
		||||
            const body = ctx.req?.body;
 | 
			
		||||
            const headers = ctx.req?.headers;
 | 
			
		||||
            const res = await ctx.fetch(url, {
 | 
			
		||||
              method: 'POST',
 | 
			
		||||
              body: body,
 | 
			
		||||
              headers: { ...headers, Authorization: `Bearer ${accessToken}` },
 | 
			
		||||
            });
 | 
			
		||||
            setBaseResponse(res);
 | 
			
		||||
            return res;
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          that.storage.removeItem('token');
 | 
			
		||||
          await that.cacheStore.clearCurrentUser();
 | 
			
		||||
        }
 | 
			
		||||
        return res;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return response as any;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 一个简单的401处理, 如果401,则刷新token, 如果refreshToken不存在,则返回401
 | 
			
		||||
   * refetch 是否重新请求, 会有bug,无限循环,按需要使用
 | 
			
		||||
   * TODO:
 | 
			
		||||
   * @param response
 | 
			
		||||
   * @param ctx
 | 
			
		||||
   * @param opts
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async run401Action(
 | 
			
		||||
    response: Result,
 | 
			
		||||
    ctx?: { req?: any; res?: any; fetch?: any },
 | 
			
		||||
    opts?: {
 | 
			
		||||
      /**
 | 
			
		||||
       * 是否重新请求, 会有bug,无限循环,按需要使用
 | 
			
		||||
       */
 | 
			
		||||
      refetch?: boolean;
 | 
			
		||||
      /**
 | 
			
		||||
       * check之后的回调
 | 
			
		||||
       */
 | 
			
		||||
      afterCheck?: (res: Result) => any;
 | 
			
		||||
      /**
 | 
			
		||||
       * 401处理后, 还是401, 则回调
 | 
			
		||||
       */
 | 
			
		||||
      afterAlso401?: (res: Result) => any;
 | 
			
		||||
    },
 | 
			
		||||
  ) {
 | 
			
		||||
    const that = this;
 | 
			
		||||
    const refetch = opts?.refetch ?? false;
 | 
			
		||||
    if (response?.code === 401) {
 | 
			
		||||
      if (that.query.stop === true) {
 | 
			
		||||
        return { code: 500, success: false, message: 'refresh token loading...' };
 | 
			
		||||
      }
 | 
			
		||||
      that.query.stop = true;
 | 
			
		||||
      const res = await that.afterCheck401ToRefreshToken(response, ctx, refetch);
 | 
			
		||||
      that.query.stop = false;
 | 
			
		||||
      opts?.afterCheck?.(res);
 | 
			
		||||
      if (res.code === 401) {
 | 
			
		||||
        opts?.afterAlso401?.(res);
 | 
			
		||||
      }
 | 
			
		||||
      return res;
 | 
			
		||||
    } else {
 | 
			
		||||
      return response as any;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 获取用户信息
 | 
			
		||||
   * @param token
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async getMe(token?: string, check401: boolean = true) {
 | 
			
		||||
    const _token = token || this.storage.getItem('token');
 | 
			
		||||
    const that = this;
 | 
			
		||||
    return that.post(
 | 
			
		||||
      { key: 'me' },
 | 
			
		||||
      {
 | 
			
		||||
        beforeRequest: async (config) => {
 | 
			
		||||
          if (config.headers) {
 | 
			
		||||
            config.headers['Authorization'] = `Bearer ${_token}`;
 | 
			
		||||
          }
 | 
			
		||||
          if (!_token) {
 | 
			
		||||
            // TODO: 取消请求,因为没有登陆
 | 
			
		||||
          }
 | 
			
		||||
          return config;
 | 
			
		||||
        },
 | 
			
		||||
        afterResponse: async (response, ctx) => {
 | 
			
		||||
          if (response?.code === 401 && check401 && !token) {
 | 
			
		||||
            return await that.afterCheck401ToRefreshToken(response, ctx);
 | 
			
		||||
          }
 | 
			
		||||
          return response as any;
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 检查本地用户,如果本地用户存在,则返回本地用户,否则返回null
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async checkLocalUser() {
 | 
			
		||||
    const user = await this.cacheStore.getCurrentUser();
 | 
			
		||||
    if (user) {
 | 
			
		||||
      return user;
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 检查本地token是否存在,简单的判断是否已经属于登陆状态
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async checkLocalToken() {
 | 
			
		||||
    const token = this.storage.getItem('token');
 | 
			
		||||
    return !!token;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 请求更新,切换用户, 使用switchUser
 | 
			
		||||
   * @param username
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  private async postSwitchUser(username: string) {
 | 
			
		||||
    return this.post({ key: 'switchCheck', data: { username } });
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 切换用户
 | 
			
		||||
   * @param username
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async switchUser(username: string) {
 | 
			
		||||
    const localUserList = await this.cacheStore.getCurrentUserList();
 | 
			
		||||
    const user = localUserList.find((userItem) => userItem.user.username === username);
 | 
			
		||||
    if (user) {
 | 
			
		||||
      this.storage.setItem('token', user.accessToken || '');
 | 
			
		||||
      await this.beforeSetLoginUser({ accessToken: user.accessToken, refreshToken: user.refreshToken });
 | 
			
		||||
      return {
 | 
			
		||||
        code: 200,
 | 
			
		||||
        data: {
 | 
			
		||||
          accessToken: user.accessToken,
 | 
			
		||||
          refreshToken: user.refreshToken,
 | 
			
		||||
        },
 | 
			
		||||
        success: true,
 | 
			
		||||
        message: '切换用户成功',
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    const res = await this.postSwitchUser(username);
 | 
			
		||||
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      const { accessToken, refreshToken } = res?.data || {};
 | 
			
		||||
      this.storage.setItem('token', accessToken || '');
 | 
			
		||||
      await this.beforeSetLoginUser({ accessToken, refreshToken });
 | 
			
		||||
    }
 | 
			
		||||
    return res;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 退出登陆,去掉token, 并删除缓存
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async logout() {
 | 
			
		||||
    this.storage.removeItem('token');
 | 
			
		||||
    const users = await this.cacheStore.getCurrentUserList();
 | 
			
		||||
    const tokens = users
 | 
			
		||||
      .map((user) => {
 | 
			
		||||
        return user?.accessToken;
 | 
			
		||||
      })
 | 
			
		||||
      .filter(Boolean);
 | 
			
		||||
    this.cacheStore.delValue();
 | 
			
		||||
    return this.post<Result>({ key: 'logout', data: { tokens } });
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 检查用户名的组,这个用户是否存在
 | 
			
		||||
   * @param username
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async hasUser(username: string) {
 | 
			
		||||
    const that = this;
 | 
			
		||||
    return this.post<Result>(
 | 
			
		||||
      {
 | 
			
		||||
        path: 'org',
 | 
			
		||||
        key: 'hasUser',
 | 
			
		||||
        data: {
 | 
			
		||||
          username,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        afterResponse: async (response, ctx) => {
 | 
			
		||||
          if (response?.code === 401) {
 | 
			
		||||
            const res = await that.afterCheck401ToRefreshToken(response, ctx, true);
 | 
			
		||||
            return res;
 | 
			
		||||
          }
 | 
			
		||||
          return response as any;
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 检查登录状态
 | 
			
		||||
   * @param token
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async checkLoginStatus(token: string) {
 | 
			
		||||
    const res = await this.post({
 | 
			
		||||
      path: 'user',
 | 
			
		||||
      key: 'checkLoginStatus',
 | 
			
		||||
      loginToken: token,
 | 
			
		||||
    });
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      const accessToken = res.data?.accessToken;
 | 
			
		||||
      this.storage.setItem('token', accessToken || '');
 | 
			
		||||
      await this.beforeSetLoginUser({ accessToken, refreshToken: res.data?.refreshToken });
 | 
			
		||||
      return res;
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 使用web登录,创建url地址, 需要MD5和jsonwebtoken
 | 
			
		||||
   */
 | 
			
		||||
  loginWithWeb(baseURL: string, { MD5, jsonwebtoken }: { MD5: any; jsonwebtoken: any }) {
 | 
			
		||||
    const randomId = Math.random().toString(36).substring(2, 15);
 | 
			
		||||
    const timestamp = Date.now();
 | 
			
		||||
    const tokenSecret = 'xiao' + randomId;
 | 
			
		||||
    const sign = MD5(`${tokenSecret}${timestamp}`).toString();
 | 
			
		||||
    const token = jsonwebtoken.sign({ randomId, timestamp, sign }, tokenSecret, {
 | 
			
		||||
      // 10分钟过期
 | 
			
		||||
      expiresIn: 60 * 10, // 10分钟
 | 
			
		||||
    });
 | 
			
		||||
    const url = `${baseURL}/api/router?path=user&key=webLogin&p&loginToken=${token}&sign=${sign}&randomId=${randomId}`;
 | 
			
		||||
    return { url, token, tokenSecret };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										154
									
								
								src/query/query-mark/query-mark.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								src/query/query-mark/query-mark.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,154 @@
 | 
			
		||||
import { Query } from '@kevisual/query';
 | 
			
		||||
import type { Result, DataOpts } from '@kevisual/query/query';
 | 
			
		||||
 | 
			
		||||
export type SimpleObject = Record<string, any>;
 | 
			
		||||
export const markType = ['simple', 'md', 'mdx', 'wallnote', 'excalidraw', 'chat'] as const;
 | 
			
		||||
export type MarkType = (typeof markType)[number];
 | 
			
		||||
export type MarkData = {
 | 
			
		||||
  nodes?: any[];
 | 
			
		||||
  edges?: any[];
 | 
			
		||||
  elements?: any[];
 | 
			
		||||
  permission?: any;
 | 
			
		||||
 | 
			
		||||
  [key: string]: any;
 | 
			
		||||
};
 | 
			
		||||
export type Mark = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  markType: MarkType;
 | 
			
		||||
  link: string;
 | 
			
		||||
  data?: MarkData;
 | 
			
		||||
  uid: string;
 | 
			
		||||
  puid: string;
 | 
			
		||||
  summary: string;
 | 
			
		||||
  thumbnail?: string;
 | 
			
		||||
  tags: string[];
 | 
			
		||||
  createdAt: string;
 | 
			
		||||
  updatedAt: string;
 | 
			
		||||
  version: number;
 | 
			
		||||
};
 | 
			
		||||
export type ShowMarkPick = Pick<Mark, 'id' | 'title' | 'description' | 'summary' | 'link' | 'tags' | 'thumbnail' | 'updatedAt'>;
 | 
			
		||||
 | 
			
		||||
export type SearchOpts = {
 | 
			
		||||
  page?: number;
 | 
			
		||||
  pageSize?: number;
 | 
			
		||||
  search?: string;
 | 
			
		||||
  sort?: string; // DESC, ASC
 | 
			
		||||
  markType?: MarkType; // 类型
 | 
			
		||||
  [key: string]: any;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type QueryMarkOpts<T extends SimpleObject = SimpleObject> = {
 | 
			
		||||
  query?: Query;
 | 
			
		||||
  isBrowser?: boolean;
 | 
			
		||||
  onLoad?: () => void;
 | 
			
		||||
} & T;
 | 
			
		||||
 | 
			
		||||
export type ResultMarkList = {
 | 
			
		||||
  list: Mark[];
 | 
			
		||||
  pagination: {
 | 
			
		||||
    pageSize: number;
 | 
			
		||||
    current: number;
 | 
			
		||||
    total: number;
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
export type QueryMarkData = {
 | 
			
		||||
  id?: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  [key: string]: any;
 | 
			
		||||
};
 | 
			
		||||
export type QueryMarkResult = {
 | 
			
		||||
  accessToken: string;
 | 
			
		||||
  refreshToken: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class QueryMarkBase<T extends SimpleObject = SimpleObject> {
 | 
			
		||||
  query: Query;
 | 
			
		||||
  isBrowser: boolean;
 | 
			
		||||
  load?: boolean;
 | 
			
		||||
  storage?: Storage;
 | 
			
		||||
  onLoad?: () => void;
 | 
			
		||||
 | 
			
		||||
  constructor(opts?: QueryMarkOpts<T>) {
 | 
			
		||||
    this.query = opts?.query || new Query();
 | 
			
		||||
    this.isBrowser = opts?.isBrowser ?? true;
 | 
			
		||||
    this.init();
 | 
			
		||||
    this.onLoad = opts?.onLoad;
 | 
			
		||||
  }
 | 
			
		||||
  setQuery(query: Query) {
 | 
			
		||||
    this.query = query;
 | 
			
		||||
  }
 | 
			
		||||
  private async init() {
 | 
			
		||||
    this.load = true;
 | 
			
		||||
    this.onLoad?.();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async post<T = Result<any>>(data: any, opts?: DataOpts): Promise<T> {
 | 
			
		||||
    try {
 | 
			
		||||
      return this.query.post({ path: 'mark', ...data }, opts) as Promise<T>;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log('error', error);
 | 
			
		||||
      return {
 | 
			
		||||
        code: 400,
 | 
			
		||||
      } as any;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getMarkList(search: SearchOpts, opts?: DataOpts) {
 | 
			
		||||
    return this.post<Result<ResultMarkList>>({ key: 'list', ...search }, opts);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getMark(id: string, opts?: DataOpts) {
 | 
			
		||||
    return this.post<Result<Mark>>({ key: 'get', id }, opts);
 | 
			
		||||
  }
 | 
			
		||||
  async getVersion(id: string, opts?: DataOpts) {
 | 
			
		||||
    return this.post<Result<{ version: number; id: string }>>({ key: 'getVersion', id }, opts);
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 检查版本
 | 
			
		||||
   * 当需要更新时,返回true
 | 
			
		||||
   * @param id
 | 
			
		||||
   * @param version
 | 
			
		||||
   * @param opts
 | 
			
		||||
   * @returns
 | 
			
		||||
   */
 | 
			
		||||
  async checkVersion(id: string, version?: number, opts?: DataOpts) {
 | 
			
		||||
    if (!version) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
    const res = await this.getVersion(id, opts);
 | 
			
		||||
    if (res.code === 200) {
 | 
			
		||||
      if (res.data!.version > version) {
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async updateMark(data: any, opts?: DataOpts) {
 | 
			
		||||
    return this.post<Result<Mark>>({ key: 'update', data }, opts);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async deleteMark(id: string, opts?: DataOpts) {
 | 
			
		||||
    return this.post<Result<Mark>>({ key: 'delete', id }, opts);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
export class QueryMark extends QueryMarkBase<SimpleObject> {
 | 
			
		||||
  markType: string;
 | 
			
		||||
  constructor(opts?: QueryMarkOpts & { markType?: MarkType }) {
 | 
			
		||||
    super(opts);
 | 
			
		||||
    this.markType = opts?.markType || 'simple';
 | 
			
		||||
  }
 | 
			
		||||
  async getMarkList(search?: SearchOpts, opts?: DataOpts) {
 | 
			
		||||
    return this.post({ key: 'list', ...search, markType: this.markType }, opts);
 | 
			
		||||
  }
 | 
			
		||||
  async updateMark(data: any, opts?: DataOpts) {
 | 
			
		||||
    if (!data.id) {
 | 
			
		||||
      data.markType = this.markType || 'simple';
 | 
			
		||||
    }
 | 
			
		||||
    return super.updateMark(data, opts);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								src/query/query-shop/defines/query-shop-define.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/query/query-shop/defines/query-shop-define.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
import { QueryUtil } from '@/query/index.ts';
 | 
			
		||||
 | 
			
		||||
export const shopDefine = QueryUtil.create({
 | 
			
		||||
  getRegistry: {
 | 
			
		||||
    path: 'shop',
 | 
			
		||||
    key: 'get-registry',
 | 
			
		||||
    description: '获取应用商店注册表信息',
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  listInstalled: {
 | 
			
		||||
    path: 'shop',
 | 
			
		||||
    key: 'list-installed',
 | 
			
		||||
    description: '列出当前已安装的所有应用',
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  install: {
 | 
			
		||||
    path: 'shop',
 | 
			
		||||
    key: 'install',
 | 
			
		||||
    description: '安装指定的应用,可以指定 id、type、force 和 yes 参数',
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  uninstall: {
 | 
			
		||||
    path: 'shop',
 | 
			
		||||
    key: 'uninstall',
 | 
			
		||||
    description: '卸载指定的应用,可以指定 id 和 type 参数',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										17
									
								
								src/query/query-shop/query-shop.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/query/query-shop/query-shop.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
import { shopDefine } from './defines/query-shop-define.ts';
 | 
			
		||||
 | 
			
		||||
import { BaseQuery, DataOpts, Query } from '@kevisual/query/query';
 | 
			
		||||
 | 
			
		||||
export { shopDefine };
 | 
			
		||||
 | 
			
		||||
export class QueryShop<T extends Query = Query> extends BaseQuery<T, typeof shopDefine> {
 | 
			
		||||
  constructor(opts?: { query: T }) {
 | 
			
		||||
    super({
 | 
			
		||||
      query: opts?.query!,
 | 
			
		||||
      queryDefine: shopDefine,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  getInstall(data: any, opts?: DataOpts) {
 | 
			
		||||
    return this.queryDefine.queryChain('install').post(data, opts);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										134
									
								
								src/query/query-upload/core/upload-chunk.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								src/query/query-upload/core/upload-chunk.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,134 @@
 | 
			
		||||
import { randomId } from '../utils/random-id.ts';
 | 
			
		||||
import { UploadProgress } from './upload-progress.ts';
 | 
			
		||||
export type ConvertOpts = {
 | 
			
		||||
  appKey?: string;
 | 
			
		||||
  version?: string;
 | 
			
		||||
  username?: string;
 | 
			
		||||
  directory?: string;
 | 
			
		||||
  isPublic?: boolean;
 | 
			
		||||
  filename?: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * 是否不检查应用文件, 默认 true,默认不检测
 | 
			
		||||
   */
 | 
			
		||||
  noCheckAppFiles?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// createEventSource: (baseUrl: string, searchParams: URLSearchParams) => {
 | 
			
		||||
//   return new EventSource(baseUrl + '/api/s1/events?' + searchParams.toString());
 | 
			
		||||
// },
 | 
			
		||||
export type UploadOpts = {
 | 
			
		||||
  uploadProgress: UploadProgress;
 | 
			
		||||
  /**
 | 
			
		||||
   * 创建 EventSource 兼容 nodejs
 | 
			
		||||
   * @param baseUrl 基础 URL
 | 
			
		||||
   * @param searchParams 查询参数
 | 
			
		||||
   * @returns EventSource
 | 
			
		||||
   */
 | 
			
		||||
  createEventSource: (baseUrl: string, searchParams: URLSearchParams) => EventSource;
 | 
			
		||||
  baseUrl?: string;
 | 
			
		||||
  token: string;
 | 
			
		||||
  FormDataFn: any;
 | 
			
		||||
};
 | 
			
		||||
export const uploadFileChunked = async (file: File, opts: ConvertOpts, opts2: UploadOpts) => {
 | 
			
		||||
  const { directory, appKey, version, username, isPublic, noCheckAppFiles = true } = opts;
 | 
			
		||||
  const { uploadProgress, createEventSource, baseUrl = '', token, FormDataFn } = opts2 || {};
 | 
			
		||||
  return new Promise(async (resolve, reject) => {
 | 
			
		||||
    const taskId = randomId();
 | 
			
		||||
    const filename = opts.filename || file.name;
 | 
			
		||||
    uploadProgress?.start(`${filename} 上传中...`);
 | 
			
		||||
 | 
			
		||||
    const searchParams = new URLSearchParams();
 | 
			
		||||
    searchParams.set('taskId', taskId);
 | 
			
		||||
    if (isPublic) {
 | 
			
		||||
      searchParams.set('public', 'true');
 | 
			
		||||
    }
 | 
			
		||||
    if (noCheckAppFiles) {
 | 
			
		||||
      searchParams.set('noCheckAppFiles', '1');
 | 
			
		||||
    }
 | 
			
		||||
    const eventSource = createEventSource(baseUrl + '/api/s1/events', searchParams);
 | 
			
		||||
    let isError = false;
 | 
			
		||||
    // 监听服务器推送的进度更新
 | 
			
		||||
    eventSource.onmessage = function (event) {
 | 
			
		||||
      console.log('Progress update:', event.data);
 | 
			
		||||
      const parseIfJson = (data: string) => {
 | 
			
		||||
        try {
 | 
			
		||||
          return JSON.parse(data);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          return data;
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      const receivedData = parseIfJson(event.data);
 | 
			
		||||
      if (typeof receivedData === 'string') return;
 | 
			
		||||
      const progress = Number(receivedData.progress);
 | 
			
		||||
      const progressFixed = progress.toFixed(2);
 | 
			
		||||
      uploadProgress?.set(progress, { ...receivedData, progressFixed, filename, taskId });
 | 
			
		||||
    };
 | 
			
		||||
    eventSource.onerror = function (event) {
 | 
			
		||||
      console.log('eventSource.onerror', event);
 | 
			
		||||
      isError = true;
 | 
			
		||||
      reject(event);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const chunkSize = 1 * 1024 * 1024; // 1MB
 | 
			
		||||
    const totalChunks = Math.ceil(file.size / chunkSize);
 | 
			
		||||
 | 
			
		||||
    for (let currentChunk = 0; currentChunk < totalChunks; currentChunk++) {
 | 
			
		||||
      const start = currentChunk * chunkSize;
 | 
			
		||||
      const end = Math.min(start + chunkSize, file.size);
 | 
			
		||||
      const chunk = file.slice(start, end);
 | 
			
		||||
 | 
			
		||||
      const formData = new FormDataFn();
 | 
			
		||||
      formData.append('file', chunk, filename);
 | 
			
		||||
      formData.append('chunkIndex', currentChunk.toString());
 | 
			
		||||
      formData.append('totalChunks', totalChunks.toString());
 | 
			
		||||
      const isLast = currentChunk === totalChunks - 1;
 | 
			
		||||
      if (directory) {
 | 
			
		||||
        formData.append('directory', directory);
 | 
			
		||||
      }
 | 
			
		||||
      if (appKey && version) {
 | 
			
		||||
        formData.append('appKey', appKey);
 | 
			
		||||
        formData.append('version', version);
 | 
			
		||||
      }
 | 
			
		||||
      if (username) {
 | 
			
		||||
        formData.append('username', username);
 | 
			
		||||
      }
 | 
			
		||||
      try {
 | 
			
		||||
        const res = await fetch(baseUrl + '/api/s1/resources/upload/chunk?taskId=' + taskId, {
 | 
			
		||||
          method: 'POST',
 | 
			
		||||
          body: formData,
 | 
			
		||||
          headers: {
 | 
			
		||||
            'task-id': taskId,
 | 
			
		||||
            Authorization: `Bearer ${token}`,
 | 
			
		||||
          },
 | 
			
		||||
        }).then((response) => response.json());
 | 
			
		||||
 | 
			
		||||
        if (res?.code !== 200) {
 | 
			
		||||
          console.log('uploadChunk error', res);
 | 
			
		||||
          uploadProgress?.error(res?.message || '上传失败');
 | 
			
		||||
          isError = true;
 | 
			
		||||
          eventSource.close();
 | 
			
		||||
 | 
			
		||||
          uploadProgress?.done();
 | 
			
		||||
          reject(new Error(res?.message || '上传失败'));
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        if (isLast) {
 | 
			
		||||
          fetch(baseUrl + '/api/s1/events/close?taskId=' + taskId);
 | 
			
		||||
          eventSource.close();
 | 
			
		||||
          uploadProgress?.done();
 | 
			
		||||
          resolve(res);
 | 
			
		||||
        }
 | 
			
		||||
        // console.log(`Chunk ${currentChunk + 1}/${totalChunks} uploaded`, res);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.log('Error uploading chunk', error);
 | 
			
		||||
        fetch(baseUrl + '/api/s1/events/close?taskId=' + taskId);
 | 
			
		||||
        reject(error);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    // 循环结束
 | 
			
		||||
    if (!uploadProgress?.end) {
 | 
			
		||||
      uploadProgress?.done();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										103
									
								
								src/query/query-upload/core/upload-progress.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/query/query-upload/core/upload-progress.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,103 @@
 | 
			
		||||
interface UploadNProgress {
 | 
			
		||||
  start: (msg?: string) => void;
 | 
			
		||||
  done: () => void;
 | 
			
		||||
  set: (progress: number) => void;
 | 
			
		||||
}
 | 
			
		||||
export type UploadProgressData = {
 | 
			
		||||
  progress: number;
 | 
			
		||||
  progressFixed: number;
 | 
			
		||||
  filename?: string;
 | 
			
		||||
  taskId?: string;
 | 
			
		||||
};
 | 
			
		||||
type UploadProgressOpts = {
 | 
			
		||||
  onStart?: () => void;
 | 
			
		||||
  onDone?: () => void;
 | 
			
		||||
  onProgress?: (progress: number, data?: UploadProgressData) => void;
 | 
			
		||||
};
 | 
			
		||||
export class UploadProgress implements UploadNProgress {
 | 
			
		||||
  /**
 | 
			
		||||
   * 进度
 | 
			
		||||
   */
 | 
			
		||||
  progress: number;
 | 
			
		||||
  /**
 | 
			
		||||
   * 开始回调
 | 
			
		||||
   */
 | 
			
		||||
  onStart: (() => void) | undefined;
 | 
			
		||||
  /**
 | 
			
		||||
   * 结束回调
 | 
			
		||||
   */
 | 
			
		||||
  onDone: (() => void) | undefined;
 | 
			
		||||
  /**
 | 
			
		||||
   * 消息回调
 | 
			
		||||
   */
 | 
			
		||||
  onProgress: ((progress: number, data?: UploadProgressData) => void) | undefined;
 | 
			
		||||
  /**
 | 
			
		||||
   * 数据
 | 
			
		||||
   */
 | 
			
		||||
  data: any;
 | 
			
		||||
  /**
 | 
			
		||||
   * 是否结束
 | 
			
		||||
   */
 | 
			
		||||
  end: boolean;
 | 
			
		||||
  constructor(uploadOpts: UploadProgressOpts) {
 | 
			
		||||
    this.progress = 0;
 | 
			
		||||
    this.end = false;
 | 
			
		||||
    const mockFn = () => {};
 | 
			
		||||
    this.onStart = uploadOpts.onStart || mockFn;
 | 
			
		||||
    this.onDone = uploadOpts.onDone || mockFn;
 | 
			
		||||
    this.onProgress = uploadOpts.onProgress || mockFn;
 | 
			
		||||
  }
 | 
			
		||||
  start(msg?: string) {
 | 
			
		||||
    this.progress = 0;
 | 
			
		||||
    msg && this.info(msg);
 | 
			
		||||
    this.end = false;
 | 
			
		||||
    this.onStart?.(); 
 | 
			
		||||
  }
 | 
			
		||||
  done() {
 | 
			
		||||
    this.progress = 100;
 | 
			
		||||
    this.end = true;
 | 
			
		||||
    this.onDone?.();
 | 
			
		||||
  }
 | 
			
		||||
  set(progress: number, data?: UploadProgressData) {
 | 
			
		||||
    this.progress = progress;
 | 
			
		||||
    this.data = data;
 | 
			
		||||
    this.onProgress?.(progress, data);
 | 
			
		||||
    console.log('uploadProgress set', progress, data);
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 开始回调
 | 
			
		||||
   */
 | 
			
		||||
  setOnStart(callback: () => void) {
 | 
			
		||||
    this.onStart = callback;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 结束回调
 | 
			
		||||
   */
 | 
			
		||||
  setOnDone(callback: () => void) {
 | 
			
		||||
    this.onDone = callback;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 消息回调
 | 
			
		||||
   */
 | 
			
		||||
  setOnProgress(callback: (progress: number, data?: UploadProgressData) => void) {
 | 
			
		||||
    this.onProgress = callback;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 打印信息
 | 
			
		||||
   */
 | 
			
		||||
  info(msg: string) {
 | 
			
		||||
    console.log(msg);
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 打印错误
 | 
			
		||||
   */
 | 
			
		||||
  error(msg: string) {
 | 
			
		||||
    console.error(msg);
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * 打印警告
 | 
			
		||||
   */
 | 
			
		||||
  warn(msg: string) {
 | 
			
		||||
    console.warn(msg);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										113
									
								
								src/query/query-upload/core/upload.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/query/query-upload/core/upload.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,113 @@
 | 
			
		||||
import { randomId } from '../utils/random-id.ts';
 | 
			
		||||
import type { UploadOpts } from './upload-chunk.ts';
 | 
			
		||||
type ConvertOpts = {
 | 
			
		||||
  appKey?: string;
 | 
			
		||||
  version?: string;
 | 
			
		||||
  username?: string;
 | 
			
		||||
  directory?: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * 文件大小限制
 | 
			
		||||
   */
 | 
			
		||||
  maxSize?: number;
 | 
			
		||||
  /**
 | 
			
		||||
   * 文件数量限制
 | 
			
		||||
   */
 | 
			
		||||
  maxCount?: number;
 | 
			
		||||
  /**
 | 
			
		||||
   * 是否不检查应用文件, 默认 true,默认不检测
 | 
			
		||||
   */
 | 
			
		||||
  noCheckAppFiles?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const uploadFiles = async (files: File[], opts: ConvertOpts, opts2: UploadOpts) => {
 | 
			
		||||
  const { directory, appKey, version, username, noCheckAppFiles = true } = opts;
 | 
			
		||||
  const { uploadProgress, createEventSource, baseUrl = '', token, FormDataFn } = opts2 || {};
 | 
			
		||||
  const length = files.length;
 | 
			
		||||
  const maxSize = opts.maxSize || 20 * 1024 * 1024; // 20MB
 | 
			
		||||
  const totalSize = files.reduce((acc, file) => acc + file.size, 0);
 | 
			
		||||
  if (totalSize > maxSize) {
 | 
			
		||||
    const maxSizeMB = maxSize / 1024 / 1024;
 | 
			
		||||
    uploadProgress?.error('有文件大小不能超过' + maxSizeMB + 'MB');
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  const maxCount = opts.maxCount || 10;
 | 
			
		||||
  if (length > maxCount) {
 | 
			
		||||
    uploadProgress?.error(`最多只能上传${maxCount}个文件`);
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  uploadProgress?.info(`上传中,共${length}个文件`);
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const formData = new FormDataFn();
 | 
			
		||||
    const webkitRelativePath = files[0]?.webkitRelativePath;
 | 
			
		||||
    const keepDirectory = webkitRelativePath !== '';
 | 
			
		||||
    const root = keepDirectory ? webkitRelativePath.split('/')[0] : '';
 | 
			
		||||
    for (let i = 0; i < files.length; i++) {
 | 
			
		||||
      const file = files[i];
 | 
			
		||||
      if (keepDirectory) {
 | 
			
		||||
        // relativePath 去除第一级
 | 
			
		||||
        const webkitRelativePath = file.webkitRelativePath.replace(root + '/', '');
 | 
			
		||||
        formData.append('file', file, webkitRelativePath); // 保留文件夹路径
 | 
			
		||||
      } else {
 | 
			
		||||
        formData.append('file', files[i], files[i].name);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (directory) {
 | 
			
		||||
      formData.append('directory', directory);
 | 
			
		||||
    }
 | 
			
		||||
    if (appKey && version) {
 | 
			
		||||
      formData.append('appKey', appKey);
 | 
			
		||||
      formData.append('version', version);
 | 
			
		||||
    }
 | 
			
		||||
    if (username) {
 | 
			
		||||
      formData.append('username', username);
 | 
			
		||||
    }
 | 
			
		||||
    const searchParams = new URLSearchParams();
 | 
			
		||||
    const taskId = randomId();
 | 
			
		||||
    searchParams.set('taskId', taskId);
 | 
			
		||||
 | 
			
		||||
    if (noCheckAppFiles) {
 | 
			
		||||
      searchParams.set('noCheckAppFiles', '1');
 | 
			
		||||
    }
 | 
			
		||||
    const eventSource = new EventSource('/api/s1/events?taskId=' + taskId);
 | 
			
		||||
 | 
			
		||||
    uploadProgress?.start('上传中...');
 | 
			
		||||
    eventSource.onopen = async function (event) {
 | 
			
		||||
      const res = await fetch('/api/s1/resources/upload?' + searchParams.toString(), {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        body: formData,
 | 
			
		||||
        headers: {
 | 
			
		||||
          'task-id': taskId,
 | 
			
		||||
          Authorization: `Bearer ${token}`,
 | 
			
		||||
        },
 | 
			
		||||
      }).then((response) => response.json());
 | 
			
		||||
 | 
			
		||||
      console.log('upload success', res);
 | 
			
		||||
      fetch('/api/s1/events/close?taskId=' + taskId);
 | 
			
		||||
      eventSource.close();
 | 
			
		||||
      uploadProgress?.done();
 | 
			
		||||
      resolve(res);
 | 
			
		||||
    };
 | 
			
		||||
    // 监听服务器推送的进度更新
 | 
			
		||||
    eventSource.onmessage = function (event) {
 | 
			
		||||
      console.log('Progress update:', event.data);
 | 
			
		||||
      const parseIfJson = (data: string) => {
 | 
			
		||||
        try {
 | 
			
		||||
          return JSON.parse(data);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          return data;
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      const receivedData = parseIfJson(event.data);
 | 
			
		||||
      if (typeof receivedData === 'string') return;
 | 
			
		||||
      const progress = Number(receivedData.progress);
 | 
			
		||||
      const progressFixed = progress.toFixed(2);
 | 
			
		||||
      console.log('progress', progress);
 | 
			
		||||
      uploadProgress?.set(progress, { ...receivedData, taskId, progressFixed });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    eventSource.onerror = function (event) {
 | 
			
		||||
      console.log('eventSource.onerror', event);
 | 
			
		||||
      reject(event);
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										51
									
								
								src/query/query-upload/query-upload-browser.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/query/query-upload/query-upload-browser.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,51 @@
 | 
			
		||||
import { UploadProgress, UploadProgressData } from './core/upload-progress.ts';
 | 
			
		||||
import { uploadFileChunked } from './core/upload-chunk.ts';
 | 
			
		||||
import { toFile, uploadFiles, randomId } from './query-upload.ts';
 | 
			
		||||
 | 
			
		||||
export { toFile, randomId };
 | 
			
		||||
export { uploadFiles, uploadFileChunked, UploadProgress };
 | 
			
		||||
 | 
			
		||||
type UploadFileProps = {
 | 
			
		||||
  onStart?: () => void;
 | 
			
		||||
  onDone?: () => void;
 | 
			
		||||
  onProgress?: (progress: number, data: UploadProgressData) => void;
 | 
			
		||||
  onSuccess?: (res: any) => void;
 | 
			
		||||
  onError?: (err: any) => void;
 | 
			
		||||
  token?: string;
 | 
			
		||||
};
 | 
			
		||||
export type ConvertOpts = {
 | 
			
		||||
  appKey?: string;
 | 
			
		||||
  version?: string;
 | 
			
		||||
  username?: string;
 | 
			
		||||
  directory?: string;
 | 
			
		||||
  isPublic?: boolean;
 | 
			
		||||
  filename?: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * 是否不检查应用文件, 默认 true,默认不检测
 | 
			
		||||
   */
 | 
			
		||||
  noCheckAppFiles?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const uploadChunk = async (file: File, opts: ConvertOpts, props?: UploadFileProps) => {
 | 
			
		||||
  const uploadProgress = new UploadProgress({
 | 
			
		||||
    onStart: function () {
 | 
			
		||||
      props?.onStart?.();
 | 
			
		||||
    },
 | 
			
		||||
    onDone: () => {
 | 
			
		||||
      props?.onDone?.();
 | 
			
		||||
    },
 | 
			
		||||
    onProgress: (progress, data) => {
 | 
			
		||||
      props?.onProgress?.(progress, data!);
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
  const result = await uploadFileChunked(file, opts, {
 | 
			
		||||
    uploadProgress,
 | 
			
		||||
    token: props?.token!,
 | 
			
		||||
    createEventSource: (url: string, searchParams: URLSearchParams) => {
 | 
			
		||||
      return new EventSource(url + '?' + searchParams.toString());
 | 
			
		||||
    },
 | 
			
		||||
    FormDataFn: FormData,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return result;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										0
									
								
								src/query/query-upload/query-upload-node.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/query/query-upload/query-upload-node.ts
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										11
									
								
								src/query/query-upload/query-upload.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/query/query-upload/query-upload.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
import { uploadFiles } from './core/upload.ts';
 | 
			
		||||
 | 
			
		||||
import { uploadFileChunked } from './core/upload-chunk.ts';
 | 
			
		||||
import { UploadProgress } from './core/upload-progress.ts';
 | 
			
		||||
 | 
			
		||||
export { uploadFiles, uploadFileChunked, UploadProgress };
 | 
			
		||||
 | 
			
		||||
export * from './utils/to-file.ts';
 | 
			
		||||
export { randomId } from './utils/random-id.ts';
 | 
			
		||||
 | 
			
		||||
export { filterFiles } from './utils/filter-files.ts';
 | 
			
		||||
							
								
								
									
										23
									
								
								src/query/query-upload/utils/filter-files.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/query/query-upload/utils/filter-files.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 过滤文件, 过滤 .DS_Store, node_modules, 以.开头的文件, 过滤 __开头的文件
 | 
			
		||||
 * @param files
 | 
			
		||||
 * @returns
 | 
			
		||||
 */
 | 
			
		||||
export const filterFiles = (files: File[]) => {
 | 
			
		||||
  files = files.filter((file) => {
 | 
			
		||||
    if (file.webkitRelativePath.startsWith('__MACOSX')) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    // 过滤node_modules
 | 
			
		||||
    if (file.webkitRelativePath.includes('node_modules')) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    // 过滤文件 .DS_Store
 | 
			
		||||
    if (file.name === '.DS_Store') {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    // 过滤以.开头的文件
 | 
			
		||||
    return !file.name.startsWith('.');
 | 
			
		||||
  });
 | 
			
		||||
  return files;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										3
									
								
								src/query/query-upload/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/query/query-upload/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
export * from './to-file.ts';
 | 
			
		||||
export * from './filter-files.ts';
 | 
			
		||||
export * from './random-id.ts';
 | 
			
		||||
							
								
								
									
										3
									
								
								src/query/query-upload/utils/random-id.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/query/query-upload/utils/random-id.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
export const randomId = () => {
 | 
			
		||||
  return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										105
									
								
								src/query/query-upload/utils/to-file.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/query/query-upload/utils/to-file.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,105 @@
 | 
			
		||||
const getFileExtension = (filename: string) => {
 | 
			
		||||
  return filename.split('.').pop();
 | 
			
		||||
};
 | 
			
		||||
const getFileType = (extension: string) => {
 | 
			
		||||
  switch (extension) {
 | 
			
		||||
    case 'js':
 | 
			
		||||
      return 'text/javascript';
 | 
			
		||||
    case 'css':
 | 
			
		||||
      return 'text/css';
 | 
			
		||||
    case 'html':
 | 
			
		||||
      return 'text/html';
 | 
			
		||||
    case 'json':
 | 
			
		||||
      return 'application/json';
 | 
			
		||||
    case 'png':
 | 
			
		||||
      return 'image/png';
 | 
			
		||||
    case 'jpg':
 | 
			
		||||
      return 'image/jpeg';
 | 
			
		||||
    case 'jpeg':
 | 
			
		||||
      return 'image/jpeg';
 | 
			
		||||
    case 'gif':
 | 
			
		||||
      return 'image/gif';
 | 
			
		||||
    case 'svg':
 | 
			
		||||
      return 'image/svg+xml';
 | 
			
		||||
    case 'webp':
 | 
			
		||||
      return 'image/webp';
 | 
			
		||||
    case 'ico':
 | 
			
		||||
      return 'image/x-icon';
 | 
			
		||||
    default:
 | 
			
		||||
      return 'text/plain';
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
const checkIsBase64 = (content: string) => {
 | 
			
		||||
  return content.startsWith('data:');
 | 
			
		||||
};
 | 
			
		||||
/**
 | 
			
		||||
 * 获取文件的目录和文件名
 | 
			
		||||
 * @param filename 文件名
 | 
			
		||||
 * @returns 目录和文件名
 | 
			
		||||
 */
 | 
			
		||||
export const getDirectoryAndName = (filename: string) => {
 | 
			
		||||
  if (!filename) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  if (filename.startsWith('.')) {
 | 
			
		||||
    return null;
 | 
			
		||||
  } else {
 | 
			
		||||
    filename = filename.replace(/^\/+/, ''); // Remove all leading slashes
 | 
			
		||||
  }
 | 
			
		||||
  const hasDirectory = filename.includes('/');
 | 
			
		||||
  if (!hasDirectory) {
 | 
			
		||||
    return { directory: '', name: filename };
 | 
			
		||||
  }
 | 
			
		||||
  const parts = filename.split('/');
 | 
			
		||||
  const name = parts.pop()!; // Get the last part as the file name
 | 
			
		||||
  const directory = parts.join('/'); // Join the remaining parts as the directory
 | 
			
		||||
  return { directory, name };
 | 
			
		||||
};
 | 
			
		||||
/**
 | 
			
		||||
 * 把字符串转为文件流,并返回文件流,根据filename的扩展名,自动设置文件类型.
 | 
			
		||||
 * 当不是文本类型,自动需要把base64的字符串转为blob
 | 
			
		||||
 * @param content 字符串
 | 
			
		||||
 * @param filename 文件名
 | 
			
		||||
 * @returns 文件流
 | 
			
		||||
 */
 | 
			
		||||
export const toFile = (content: string, filename: string) => {
 | 
			
		||||
  // 如果文件名是 a/d/a.js 格式的,则需要把d作为目录,a.js作为文件名
 | 
			
		||||
  const directoryAndName = getDirectoryAndName(filename);
 | 
			
		||||
  if (!directoryAndName) {
 | 
			
		||||
    throw new Error('Invalid filename');
 | 
			
		||||
  }
 | 
			
		||||
  const { name } = directoryAndName;
 | 
			
		||||
  const extension = getFileExtension(name);
 | 
			
		||||
  if (!extension) {
 | 
			
		||||
    throw new Error('Invalid filename');
 | 
			
		||||
  }
 | 
			
		||||
  const isBase64 = checkIsBase64(content);
 | 
			
		||||
  const type = getFileType(extension);
 | 
			
		||||
 | 
			
		||||
  if (isBase64) {
 | 
			
		||||
    // Decode base64 string
 | 
			
		||||
    const base64Data = content.split(',')[1]; // Remove the data URL prefix
 | 
			
		||||
    const byteCharacters = atob(base64Data);
 | 
			
		||||
    const byteNumbers = new Array(byteCharacters.length);
 | 
			
		||||
    for (let i = 0; i < byteCharacters.length; i++) {
 | 
			
		||||
      byteNumbers[i] = byteCharacters.charCodeAt(i);
 | 
			
		||||
    }
 | 
			
		||||
    const byteArray = new Uint8Array(byteNumbers);
 | 
			
		||||
    const blob = new Blob([byteArray], { type });
 | 
			
		||||
    return new File([blob], filename, { type });
 | 
			
		||||
  } else {
 | 
			
		||||
    const blob = new Blob([content], { type });
 | 
			
		||||
    return new File([blob], filename, { type });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 把字符串转为文本文件
 | 
			
		||||
 * @param content 字符串
 | 
			
		||||
 * @param filename 文件名
 | 
			
		||||
 * @returns 文件流
 | 
			
		||||
 */
 | 
			
		||||
export const toTextFile = (content: string = 'keep directory exist', filename: string = 'keep.txt') => {
 | 
			
		||||
  const file = toFile(content, filename);
 | 
			
		||||
  return file;
 | 
			
		||||
};
 | 
			
		||||
		Reference in New Issue
	
	Block a user