add base
This commit is contained in:
		
							
								
								
									
										5
									
								
								.npmrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.npmrc
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
 | 
				
			||||||
 | 
					@abearxiong:registry=https://npm.pkg.github.com
 | 
				
			||||||
 | 
					//registry.npmjs.org/:_authToken=${NPM_TOKEN}
 | 
				
			||||||
 | 
					@build:registry=https://npm.xiongxiao.me
 | 
				
			||||||
 | 
					@kevisual:registry=https://npm.xiongxiao.me
 | 
				
			||||||
							
								
								
									
										14
									
								
								index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="en">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					  <meta charset="UTF-8">
 | 
				
			||||||
 | 
					  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
 | 
					  <title>Browser Apps</title>
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<body>
 | 
				
			||||||
 | 
					  <script src="./src/main.tsx" type="module"></script>
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										28
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								package.json
									
									
									
									
									
								
							@@ -4,13 +4,35 @@
 | 
				
			|||||||
  "description": "",
 | 
					  "description": "",
 | 
				
			||||||
  "main": "index.js",
 | 
					  "main": "index.js",
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "test": "echo \"Error: no test specified\" && exit 1"
 | 
					    "dev": "vite",
 | 
				
			||||||
 | 
					    "build": "vite build"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "keywords": [],
 | 
					  "keywords": [],
 | 
				
			||||||
  "author": "abearxiong <xiongxiao@xiongxiao.me>",
 | 
					  "author": "abearxiong <xiongxiao@xiongxiao.me>",
 | 
				
			||||||
  "license": "MIT",
 | 
					  "license": "MIT",
 | 
				
			||||||
  "type": "module",
 | 
					  "type": "module",
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "vite": "^6.0.1"
 | 
					    "@kevisual/router": "workspace:*",
 | 
				
			||||||
 | 
					    "@kevisual/store": "workspace:*",
 | 
				
			||||||
 | 
					    "@vitejs/plugin-react": "^4.3.4",
 | 
				
			||||||
 | 
					    "clsx": "^2.1.1",
 | 
				
			||||||
 | 
					    "eventemitter3": "^5.0.1",
 | 
				
			||||||
 | 
					    "highlight.js": "^11.10.0",
 | 
				
			||||||
 | 
					    "immer": "^10.1.1",
 | 
				
			||||||
 | 
					    "lodash-es": "^4.17.21",
 | 
				
			||||||
 | 
					    "marked": "^15.0.3",
 | 
				
			||||||
 | 
					    "marked-highlight": "^2.2.1",
 | 
				
			||||||
 | 
					    "nanoid": "^5.0.9",
 | 
				
			||||||
 | 
					    "react": "^18.3.1",
 | 
				
			||||||
 | 
					    "vite": "^6.0.1",
 | 
				
			||||||
 | 
					    "zustand": "^5.0.1"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "devDependencies": {
 | 
				
			||||||
 | 
					    "@build/tailwind": "1.0.2-alpha-2",
 | 
				
			||||||
 | 
					    "@types/lodash-es": "^4.17.12",
 | 
				
			||||||
 | 
					    "@types/react": "^18.3.12",
 | 
				
			||||||
 | 
					    "@types/umami": "^2.10.0",
 | 
				
			||||||
 | 
					    "autoprefixer": "^10.4.20",
 | 
				
			||||||
 | 
					    "tailwindcss": "^3.4.15"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										91
									
								
								src/h.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/h.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					import { nanoid } from 'nanoid';
 | 
				
			||||||
 | 
					import { RefObject, SyntheticEvent } from 'react';
 | 
				
			||||||
 | 
					const loadChidren = (element: any, children: any[]) => {
 | 
				
			||||||
 | 
					  children.forEach((child) => {
 | 
				
			||||||
 | 
					    if (typeof child === 'boolean') {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (typeof child === 'function') {
 | 
				
			||||||
 | 
					      console.log('child', child);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (typeof child === 'undefined') {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    console.log('child', child);
 | 
				
			||||||
 | 
					    element.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					// 在项目中定义 h 函数
 | 
				
			||||||
 | 
					export function h(type: string | Function, props: any, ...children: any[]): HTMLElement {
 | 
				
			||||||
 | 
					  if (typeof type === 'function') {
 | 
				
			||||||
 | 
					    const element = type(props);
 | 
				
			||||||
 | 
					    loadChidren(element, children);
 | 
				
			||||||
 | 
					    return element;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  const element = document.createElement(type);
 | 
				
			||||||
 | 
					  const filterKeys = ['onLoad', 'onUnload', 'key'];
 | 
				
			||||||
 | 
					  const key = props?.key || nanoid();
 | 
				
			||||||
 | 
					  Object.entries(props || {}).forEach(([key, value]) => {
 | 
				
			||||||
 | 
					    if (filterKeys.includes(key)) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (key === 'className') {
 | 
				
			||||||
 | 
					      element.setAttribute('class', value as string);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (key.startsWith('on')) {
 | 
				
			||||||
 | 
					      element.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (key === 'ref' && value) {
 | 
				
			||||||
 | 
					      (value as any).current = element;
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (typeof value === 'object') {
 | 
				
			||||||
 | 
					      console.log('error', element, type, value);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      element.setAttribute(key, value as string);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  const onLoad = props?.onLoad;
 | 
				
			||||||
 | 
					  const checkConnect = () => {
 | 
				
			||||||
 | 
					    if (element.isConnected) {
 | 
				
			||||||
 | 
					      onLoad?.({ el: element, key, _props: props });
 | 
				
			||||||
 | 
					      console.log('onLoad', element, key);
 | 
				
			||||||
 | 
					      return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return false;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  setTimeout(() => {
 | 
				
			||||||
 | 
					    const res = checkConnect();
 | 
				
			||||||
 | 
					    if (!res) {
 | 
				
			||||||
 | 
					      setTimeout(() => {}, 1000);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, 20);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  loadChidren(element, children);
 | 
				
			||||||
 | 
					  return element;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					declare global {
 | 
				
			||||||
 | 
					  namespace JSX {
 | 
				
			||||||
 | 
					    // type Element = HTMLElement; // 将 JSX.Element 设置为 HTMLElement
 | 
				
			||||||
 | 
					    interface Element extends HTMLElement {
 | 
				
			||||||
 | 
					      class?: string;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  namespace React {
 | 
				
			||||||
 | 
					    interface FormEvent<T = Element> extends SyntheticEvent<T> {
 | 
				
			||||||
 | 
					      target: EventTarget & (T extends HTMLInputElement ? HTMLInputElement : T);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const useRef = <T = HTMLDivElement>(initialValue: T | null = null): RefObject<T> => {
 | 
				
			||||||
 | 
					  return { current: initialValue };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const useEffect = (callback: () => void) => {
 | 
				
			||||||
 | 
					  setTimeout(callback, 0);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										21
									
								
								src/main.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/main.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					@tailwind components;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@layer components {
 | 
				
			||||||
 | 
					  .chat-message {
 | 
				
			||||||
 | 
					    @apply flex flex-col gap-2;
 | 
				
			||||||
 | 
					    .message-content {
 | 
				
			||||||
 | 
					      @apply px-8 mt-2;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    .message-user {
 | 
				
			||||||
 | 
					      @apply flex-row-reverse;
 | 
				
			||||||
 | 
					      .message-content-wrapper {
 | 
				
			||||||
 | 
					        @apply max-w-[66%];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      .message-content {
 | 
				
			||||||
 | 
					        @apply bg-gray-200 px-4 py-2 rounded-lg;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    .message-assistant {
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										32
									
								
								src/main.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/main.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
				
			|||||||
 | 
					import { h } from '@/h';
 | 
				
			||||||
 | 
					import '@build/tailwind/main.css';
 | 
				
			||||||
 | 
					// tab的app-routes模块
 | 
				
			||||||
 | 
					import './tab';
 | 
				
			||||||
 | 
					import './page/index.css';
 | 
				
			||||||
 | 
					import './main.css';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { Page } from '@kevisual/store/page';
 | 
				
			||||||
 | 
					import { AiChat } from './page/AiChat';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const div = document.createElement('div');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.className = 'text-4xl text-center p-4 bg-blue-200';
 | 
				
			||||||
 | 
					div.innerHTML = 'Browser Apps';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const page = new Page({
 | 
				
			||||||
 | 
					  path: '',
 | 
				
			||||||
 | 
					  key: '',
 | 
				
			||||||
 | 
					  basename: '',
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					page.addPage('/home', 'home');
 | 
				
			||||||
 | 
					page.addPage('/ai', 'ai');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					page.subscribe('home', (page) => {
 | 
				
			||||||
 | 
					  document.body.appendChild(div);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					page.subscribe('ai', (page) => {
 | 
				
			||||||
 | 
					  const aiChat = AiChat();
 | 
				
			||||||
 | 
					  console.log(aiChat);
 | 
				
			||||||
 | 
					  document.body.appendChild(aiChat);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										109
									
								
								src/modules/ResponseText.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/modules/ResponseText.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,109 @@
 | 
				
			|||||||
 | 
					import { throttle } from 'lodash-es';
 | 
				
			||||||
 | 
					import { Marked } from 'marked';
 | 
				
			||||||
 | 
					import 'highlight.js/styles/github.css'; // 你可以选择其他样式
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					import { h, useRef, useEffect } from '@/h';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ResponseTextProps = {
 | 
				
			||||||
 | 
					  response: Response;
 | 
				
			||||||
 | 
					  onFinish?: (text: string) => void;
 | 
				
			||||||
 | 
					  onChange?: (text: string) => void;
 | 
				
			||||||
 | 
					  className?: string;
 | 
				
			||||||
 | 
					  id?: string;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export const ResponseText = (props: ResponseTextProps) => {
 | 
				
			||||||
 | 
					  const ref = useRef();
 | 
				
			||||||
 | 
					  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();
 | 
				
			||||||
 | 
					  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);
 | 
				
			||||||
 | 
					  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();
 | 
				
			||||||
 | 
					  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>;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										164
									
								
								src/page/AiChat.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								src/page/AiChat.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,164 @@
 | 
				
			|||||||
 | 
					import { h, useRef } from '@/h';
 | 
				
			||||||
 | 
					import { ResponseText, ResponseMarkdown, Markdown } from '@/modules/ResponseText';
 | 
				
			||||||
 | 
					import { aiChatStore, store } from '@/store/ai-chat-store';
 | 
				
			||||||
 | 
					import clsx from 'clsx';
 | 
				
			||||||
 | 
					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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					type MessageProps = {} & MessageData;
 | 
				
			||||||
 | 
					const UserMessage = (props: MessageProps) => {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className={`whitespace-pre message-item ${props.role}`} id={props.id}>
 | 
				
			||||||
 | 
					      {props.content}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					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 WrapperMessage = isUser ? UserMessage : AssistantMessage;
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className={clsx('pt-6 w-[80%] mx-auto chat-message-wrapper', props.className)}>
 | 
				
			||||||
 | 
					      <div className={clsx('chat-message')}>
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          className={clsx(
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              'message-user': isUser,
 | 
				
			||||||
 | 
					              'message-assistant': !isUser,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            'flex flex-col',
 | 
				
			||||||
 | 
					          )}>
 | 
				
			||||||
 | 
					          <div className='message-content-wrapper'>
 | 
				
			||||||
 | 
					            <div className={clsx('message-avatar flex gap-3 items-center', isUser && 'hidden')}>
 | 
				
			||||||
 | 
					              <img src='https://minio.xiongxiao.me/resources/root/avatar.png' alt='' className='w-6 h-6 rounded-full' />
 | 
				
			||||||
 | 
					              <div>AI 助手</div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div className='message-content scrollbar overflow-x-hidden'>
 | 
				
			||||||
 | 
					              <WrapperMessage {...props} />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export const AiChat = () => {
 | 
				
			||||||
 | 
					  const ref = useRef();
 | 
				
			||||||
 | 
					  const aiMessageRef = useRef();
 | 
				
			||||||
 | 
					  const textareaRef = useRef<HTMLTextAreaElement>();
 | 
				
			||||||
 | 
					  const scrollContainer = useRef();
 | 
				
			||||||
 | 
					  store.subscribe(
 | 
				
			||||||
 | 
					    (state, prevState) => {
 | 
				
			||||||
 | 
					      const messages = state.messages;
 | 
				
			||||||
 | 
					      console.log('store', state, prevState);
 | 
				
			||||||
 | 
					      if (aiMessageRef.current) {
 | 
				
			||||||
 | 
					        messages.forEach((message) => {
 | 
				
			||||||
 | 
					          const id = message.id;
 | 
				
			||||||
 | 
					          if (document.getElementById('msg-' + id)) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          // let msg: JSX.Element;
 | 
				
			||||||
 | 
					          // if (message.role === 'ai' && message.response) {
 | 
				
			||||||
 | 
					          //   msg = ResponseMarkdown({ response: message.response, className: 'message-item ai', onFinish: () => {} });
 | 
				
			||||||
 | 
					          // } else if (message.role === 'ai' && message.content) {
 | 
				
			||||||
 | 
					          //   msg = AssistantMessage({ ...message, className: 'message-item ai' });
 | 
				
			||||||
 | 
					          // } else {
 | 
				
			||||||
 | 
					          //   msg = UserMessage({ ...message, id });
 | 
				
			||||||
 | 
					          // }
 | 
				
			||||||
 | 
					          const onChange = (text: string) => {
 | 
				
			||||||
 | 
					            // const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
 | 
				
			||||||
 | 
					            const scrollContainerEl = scrollContainer.current;
 | 
				
			||||||
 | 
					            const bottom = document.querySelector('.ai-message-bottom');
 | 
				
			||||||
 | 
					            if (scrollContainerEl) {
 | 
				
			||||||
 | 
					              // 如果滚动条距离底部小于100px,自动滚动到底部
 | 
				
			||||||
 | 
					              if (scrollContainerEl.scrollHeight - scrollContainerEl.scrollTop - scrollContainerEl.clientHeight < 400) {
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              console.log('scrollContainerEl', scrollContainerEl.scrollHeight, scrollContainerEl.scrollTop, scrollContainerEl.clientHeight);
 | 
				
			||||||
 | 
					              console.log('scrollContainerEl<400', scrollContainerEl.scrollHeight - scrollContainerEl.scrollTop - scrollContainerEl.clientHeight < 400);
 | 
				
			||||||
 | 
					              bottom?.scrollIntoView({ behavior: 'smooth' });
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          const onFinish = (text: string) => {
 | 
				
			||||||
 | 
					            aiChatStore.getState().setEnd(true);
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          const msg = Message({ ...message, id, onChange, onFinish });
 | 
				
			||||||
 | 
					          aiMessageRef.current!.appendChild(msg);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    { key: 'aiStore', path: 'messages' },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const onSend = () => {
 | 
				
			||||||
 | 
					    const input = textareaRef.current;
 | 
				
			||||||
 | 
					    const state = aiChatStore.getState();
 | 
				
			||||||
 | 
					    if (state.loading || !state.end) {
 | 
				
			||||||
 | 
					      console.log('loading');
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (input && input.value.trim() !== '') {
 | 
				
			||||||
 | 
					      aiChatStore.getState().sendMessage(input.value);
 | 
				
			||||||
 | 
					      input.value = '';
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      console.log('输入为空');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      ref={ref}
 | 
				
			||||||
 | 
					      className='w-full h-full  bg-blue-400 flex justify-center'
 | 
				
			||||||
 | 
					      onLoad={(e) => {
 | 
				
			||||||
 | 
					        console.log('onLoad======', e, ref.current);
 | 
				
			||||||
 | 
					      }}>
 | 
				
			||||||
 | 
					      <div className='w-[80%] border p-2 shadow bg-white mt-2 mb-2 rounded-lg  flex flex-col'>
 | 
				
			||||||
 | 
					        <h1 className='text-2xl font-bold text-gray-800 text-center'>AI聊天</h1>
 | 
				
			||||||
 | 
					        <div className='relative flex-grow overflow-auto scrollbar' ref={scrollContainer}>
 | 
				
			||||||
 | 
					          <div className='message mt-2 mb-5 m-h-[40px]' id='ai-message-content' ref={aiMessageRef}></div>
 | 
				
			||||||
 | 
					          <div className='ai-message-bottom mb-4'></div>
 | 
				
			||||||
 | 
					          <div className='flex w-full sticky justify-center bottom-0'>
 | 
				
			||||||
 | 
					            <div className='w-[80%] flex flex-col gap-2 '>
 | 
				
			||||||
 | 
					              <label htmlFor='inputField' className='text-lg font-medium text-gray-700'>
 | 
				
			||||||
 | 
					                输入框
 | 
				
			||||||
 | 
					              </label>
 | 
				
			||||||
 | 
					              <textarea
 | 
				
			||||||
 | 
					                id='inputField'
 | 
				
			||||||
 | 
					                ref={textareaRef}
 | 
				
			||||||
 | 
					                onInput={(e) => {
 | 
				
			||||||
 | 
					                  // console.log(e.target?.value); // 控制台输出输入的值
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					                onKeyDown={(e) => {
 | 
				
			||||||
 | 
					                  if (e.key === 'Enter' && !e.shiftKey) {
 | 
				
			||||||
 | 
					                    e.preventDefault(); // 防止换行
 | 
				
			||||||
 | 
					                    onSend();
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					                className='w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200'
 | 
				
			||||||
 | 
					                placeholder='请输入内容'
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					              <button
 | 
				
			||||||
 | 
					                className='bg-blue-500 text-white p-2 rounded disabled:bg-gray-400'
 | 
				
			||||||
 | 
					                onClick={() => {
 | 
				
			||||||
 | 
					                  onSend();
 | 
				
			||||||
 | 
					                }}>
 | 
				
			||||||
 | 
					                提交
 | 
				
			||||||
 | 
					              </button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										25
									
								
								src/page/index.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/page/index.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
				
			|||||||
 | 
					.markdown-body {
 | 
				
			||||||
 | 
					  font-family: Arial, sans-serif;
 | 
				
			||||||
 | 
					  line-height: 1.6;
 | 
				
			||||||
 | 
					  color: #333;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.markdown-body h1,
 | 
				
			||||||
 | 
					.markdown-body h2,
 | 
				
			||||||
 | 
					.markdown-body h3 {
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #eee;
 | 
				
			||||||
 | 
					  padding-bottom: 0.3em;
 | 
				
			||||||
 | 
					  margin-top: 1em;
 | 
				
			||||||
 | 
					  margin-bottom: 0.3rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.markdown-body a {
 | 
				
			||||||
 | 
					  color: #facc15;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.markdown-body code {
 | 
				
			||||||
 | 
					  background: #f6f8fa;
 | 
				
			||||||
 | 
					  padding: 0.2em 0.4em;
 | 
				
			||||||
 | 
					  border-radius: 3px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										81
									
								
								src/response/ollama/ollama.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/response/ollama/ollama.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
				
			|||||||
 | 
					import { createOllamaChatStream } from './stream-chat';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const ollamaUrl = 'http://192.168.31.220:11434';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const createResponse = (response: Response) => {
 | 
				
			||||||
 | 
					  const reader = response.body!.getReader();
 | 
				
			||||||
 | 
					  const decoder = new TextDecoder('utf-8');
 | 
				
			||||||
 | 
					  let done = false;
 | 
				
			||||||
 | 
					  let result = '';
 | 
				
			||||||
 | 
					  reader.read().then(function processText({ done, value }) {
 | 
				
			||||||
 | 
					    if (done) {
 | 
				
			||||||
 | 
					      console.log('Stream complete');
 | 
				
			||||||
 | 
					      done = true;
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    result += decoder.decode(value, { stream: true });
 | 
				
			||||||
 | 
					    return reader.read().then(processText);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  return result;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					type OllamaResponse = {
 | 
				
			||||||
 | 
					  context: number[];
 | 
				
			||||||
 | 
					  created_at: string;
 | 
				
			||||||
 | 
					  done: boolean;
 | 
				
			||||||
 | 
					  done_reason: string;
 | 
				
			||||||
 | 
					  eval_count: number;
 | 
				
			||||||
 | 
					  eval_duration: number;
 | 
				
			||||||
 | 
					  model: string;
 | 
				
			||||||
 | 
					  prompt_eval_count: number;
 | 
				
			||||||
 | 
					  prompt_eval_duration: number;
 | 
				
			||||||
 | 
					  response: string;
 | 
				
			||||||
 | 
					  total_duration: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export const postOllama = async (prompt: string): Promise<OllamaResponse> => {
 | 
				
			||||||
 | 
					  const response = await fetch(`${ollamaUrl}/api/generate`, {
 | 
				
			||||||
 | 
					    method: 'POST',
 | 
				
			||||||
 | 
					    headers: {
 | 
				
			||||||
 | 
					      'Content-Type': 'application/json',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    body: JSON.stringify({
 | 
				
			||||||
 | 
					      model: 'qwen2.5:14b',
 | 
				
			||||||
 | 
					      prompt,
 | 
				
			||||||
 | 
					      stream: false,
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  return await response.json();
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export const previewResponse = async (response: Response) => {
 | 
				
			||||||
 | 
					  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 });
 | 
				
			||||||
 | 
					      console.log(chunk);
 | 
				
			||||||
 | 
					      // 在页面实时显示流返回的内容
 | 
				
			||||||
 | 
					      document.getElementById('output')!.innerText += chunk;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export const postOllamaStrem = async (prompt: string) => {
 | 
				
			||||||
 | 
					  const res = await fetch(`${ollamaUrl}/api/generate`, {
 | 
				
			||||||
 | 
					    method: 'POST',
 | 
				
			||||||
 | 
					    headers: {
 | 
				
			||||||
 | 
					      'Content-Type': 'application/json',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    body: JSON.stringify({
 | 
				
			||||||
 | 
					      model: 'qwen2.5:14b',
 | 
				
			||||||
 | 
					      prompt: prompt,
 | 
				
			||||||
 | 
					      stream: true, // 启用流式返回
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const readSteam = await createOllamaChatStream(res);
 | 
				
			||||||
 | 
					  // previewResponse(readSteam)
 | 
				
			||||||
 | 
					  return readSteam;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										0
									
								
								src/response/ollama/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/response/ollama/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										106
									
								
								src/response/ollama/stream-chat.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/response/ollama/stream-chat.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
				
			|||||||
 | 
					export const StreamChat = async (response: Response) => {
 | 
				
			||||||
 | 
					  // const response = await fetch(request);
 | 
				
			||||||
 | 
					  const reader = response?.body!.getReader();
 | 
				
			||||||
 | 
					  const decoder = new TextDecoder();
 | 
				
			||||||
 | 
					  const encoder = new TextEncoder();
 | 
				
			||||||
 | 
					  const readableStream = new ReadableStream({
 | 
				
			||||||
 | 
					    async start(controller) {
 | 
				
			||||||
 | 
					      function push() {
 | 
				
			||||||
 | 
					        reader
 | 
				
			||||||
 | 
					          .read()
 | 
				
			||||||
 | 
					          .then(({ done, value }) => {
 | 
				
			||||||
 | 
					            if (done) {
 | 
				
			||||||
 | 
					              try {
 | 
				
			||||||
 | 
					                controller.close();
 | 
				
			||||||
 | 
					              } catch (e) {
 | 
				
			||||||
 | 
					                //
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            const chunk = decoder.decode(value, { stream: true });
 | 
				
			||||||
 | 
					            const messageList = chunk.split('\n\n').filter((message) => message);
 | 
				
			||||||
 | 
					            messageList.forEach((message) => {
 | 
				
			||||||
 | 
					              // const message = chunk.replace('data: ', '');
 | 
				
			||||||
 | 
					              message = message.replace('data: ', '');
 | 
				
			||||||
 | 
					              console.log(chunk);
 | 
				
			||||||
 | 
					              console.log(message);
 | 
				
			||||||
 | 
					              if (message === '[DONE]') {
 | 
				
			||||||
 | 
					                // controller.close();
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              const parsed = JSON.parse(message);
 | 
				
			||||||
 | 
					              controller.enqueue(encoder.encode(parsed.choices[0].delta.content));
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					            push();
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					          .catch((err) => {
 | 
				
			||||||
 | 
					            console.error('读取流中的数据时发生错误', err);
 | 
				
			||||||
 | 
					            controller.error(err);
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      push();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  return new Response(readableStream);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const createOllamaChatStream = async (response: Response) => {
 | 
				
			||||||
 | 
					  // const response = await fetch(request);
 | 
				
			||||||
 | 
					  const reader = response?.body!.getReader();
 | 
				
			||||||
 | 
					  const decoder = new TextDecoder();
 | 
				
			||||||
 | 
					  const encoder = new TextEncoder();
 | 
				
			||||||
 | 
					  const readableStream = new ReadableStream({
 | 
				
			||||||
 | 
					    async start(controller) {
 | 
				
			||||||
 | 
					      function push() {
 | 
				
			||||||
 | 
					        reader
 | 
				
			||||||
 | 
					          .read()
 | 
				
			||||||
 | 
					          .then(({ done, value }) => {
 | 
				
			||||||
 | 
					            if (done) {
 | 
				
			||||||
 | 
					              try {
 | 
				
			||||||
 | 
					                controller.close();
 | 
				
			||||||
 | 
					              } catch (e) {
 | 
				
			||||||
 | 
					                //
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            const chunk = decoder.decode(value, { stream: true });
 | 
				
			||||||
 | 
					            // try {
 | 
				
			||||||
 | 
					            //   const parsed = JSON.parse(chunk)
 | 
				
			||||||
 | 
					            //   console.log(parsed)
 | 
				
			||||||
 | 
					            //   controller.enqueue(encoder.encode(parsed.response))
 | 
				
			||||||
 | 
					            //   push()
 | 
				
			||||||
 | 
					            // } catch (e) {
 | 
				
			||||||
 | 
					            //   console.error('解析流中的数据时发生错误', e)
 | 
				
			||||||
 | 
					            //   console.error('解析的流', chunk)
 | 
				
			||||||
 | 
					            //   // controller.error(e)
 | 
				
			||||||
 | 
					            // }
 | 
				
			||||||
 | 
					            // console.log('parsex chunk', chunk)
 | 
				
			||||||
 | 
					            const messageList = chunk.split('\n').filter((message) => message);
 | 
				
			||||||
 | 
					            messageList.forEach((message) => {
 | 
				
			||||||
 | 
					              // console.log('message', message, '\nchunk:', chunk)
 | 
				
			||||||
 | 
					              if (message === '[DONE]') {
 | 
				
			||||||
 | 
					                // controller.close();
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              try {
 | 
				
			||||||
 | 
					                const parsed = JSON.parse(message);
 | 
				
			||||||
 | 
					                controller.enqueue(encoder.encode(parsed.response));
 | 
				
			||||||
 | 
					                push();
 | 
				
			||||||
 | 
					              } catch (e) {
 | 
				
			||||||
 | 
					                console.error('解析流中的数据时发生错误', e);
 | 
				
			||||||
 | 
					                console.error('解析的流', message);
 | 
				
			||||||
 | 
					                // controller.error(e)
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					          .catch((err) => {
 | 
				
			||||||
 | 
					            console.error('读取流中的数据时发生错误', err);
 | 
				
			||||||
 | 
					            controller.error(err);
 | 
				
			||||||
 | 
					            push();
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      push();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  return new Response(readableStream);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										0
									
								
								src/route-page.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/route-page.ts
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										75
									
								
								src/store/ai-chat-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/store/ai-chat-store.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
				
			|||||||
 | 
					import { StoreManager } from '@kevisual/store';
 | 
				
			||||||
 | 
					import type { StateCreator, StoreApi } from 'zustand';
 | 
				
			||||||
 | 
					import { useContextKey } from '@kevisual/store/context';
 | 
				
			||||||
 | 
					import { postOllama, postOllamaStrem } from '@/response/ollama/ollama';
 | 
				
			||||||
 | 
					import { nanoid } from 'nanoid';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const store = useContextKey('storeManager', () => {
 | 
				
			||||||
 | 
					  return new StoreManager();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type AiChatMessage = {
 | 
				
			||||||
 | 
					  content?: string;
 | 
				
			||||||
 | 
					  role?: 'user' | 'ai';
 | 
				
			||||||
 | 
					  id?: string;
 | 
				
			||||||
 | 
					  response?: Response;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					type AiChatStore = {
 | 
				
			||||||
 | 
					  messages: AiChatMessage[];
 | 
				
			||||||
 | 
					  loading: boolean;
 | 
				
			||||||
 | 
					  setLoading: (loading: boolean) => void;
 | 
				
			||||||
 | 
					  sendMessage: (text: string) => Promise<any>;
 | 
				
			||||||
 | 
					  end: boolean;
 | 
				
			||||||
 | 
					  setEnd: (end: boolean) => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export const createAiChatStore: StateCreator<AiChatStore, [], [], AiChatStore> = (set, get) => {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    messages: [],
 | 
				
			||||||
 | 
					    loading: false,
 | 
				
			||||||
 | 
					    end: true,
 | 
				
			||||||
 | 
					    setEnd: (end) => {
 | 
				
			||||||
 | 
					      set({ end });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    setLoading: (loading) => {
 | 
				
			||||||
 | 
					      set({ loading });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    sendMessage: async (text) => {
 | 
				
			||||||
 | 
					      // const res = await postOllama(text);
 | 
				
			||||||
 | 
					      const id = nanoid(8);
 | 
				
			||||||
 | 
					      // const getDataFromOllamaJson = (value: typeof res) => {
 | 
				
			||||||
 | 
					      //   const id = nanoid(8);
 | 
				
			||||||
 | 
					      //   return {
 | 
				
			||||||
 | 
					      //     content: value.response as string,
 | 
				
			||||||
 | 
					      //     role: 'ai' as const,
 | 
				
			||||||
 | 
					      //     id: id,
 | 
				
			||||||
 | 
					      //   };
 | 
				
			||||||
 | 
					      // };
 | 
				
			||||||
 | 
					      // const data = getDataFromOllamaJson(res);
 | 
				
			||||||
 | 
					      set({ loading: true });
 | 
				
			||||||
 | 
					      const newMessage = [
 | 
				
			||||||
 | 
					        ...get().messages,
 | 
				
			||||||
 | 
					        { content: text, role: 'user' as const, id: id }, //
 | 
				
			||||||
 | 
					      ];
 | 
				
			||||||
 | 
					      // set({
 | 
				
			||||||
 | 
					      //   messages: newMessage,
 | 
				
			||||||
 | 
					      // });
 | 
				
			||||||
 | 
					      const resStream = await postOllamaStrem(text);
 | 
				
			||||||
 | 
					      set({ loading: false });
 | 
				
			||||||
 | 
					      const getDataFromOllamaStream = (value: Response) => {
 | 
				
			||||||
 | 
					        const id = nanoid(8);
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          role: 'ai' as const,
 | 
				
			||||||
 | 
					          id: id,
 | 
				
			||||||
 | 
					          response: value,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      const dataStream = getDataFromOllamaStream(resStream);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      set({
 | 
				
			||||||
 | 
					        messages: [...newMessage, dataStream],
 | 
				
			||||||
 | 
					        end: false,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export const aiChatStore: StoreApi<AiChatStore> = store.createStore(createAiChatStore, 'aiStore');
 | 
				
			||||||
							
								
								
									
										446
									
								
								src/tab.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										446
									
								
								src/tab.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,446 @@
 | 
				
			|||||||
 | 
					import { nanoid } from 'nanoid';
 | 
				
			||||||
 | 
					import { QueryRouterServer } from '@kevisual/router/browser';
 | 
				
			||||||
 | 
					import { useContextKey } from '@kevisual/store/context';
 | 
				
			||||||
 | 
					import { useConfigKey } from '@kevisual/store/config';
 | 
				
			||||||
 | 
					import { EventEmitter } from 'eventemitter3';
 | 
				
			||||||
 | 
					// const errorCdoe = {
 | 
				
			||||||
 | 
					//   840: 'not me', // 不是我
 | 
				
			||||||
 | 
					//   999: 'find max', // 找到最大的
 | 
				
			||||||
 | 
					// };
 | 
				
			||||||
 | 
					export const emitter = useContextKey('emitter', () => {
 | 
				
			||||||
 | 
					  return new EventEmitter();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const tabId = useConfigKey('tabId', () => {
 | 
				
			||||||
 | 
					  return nanoid(8);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					export const openTabs = useContextKey('openTabs', () => {
 | 
				
			||||||
 | 
					  return new Set<string>();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					export const tabConfig = useConfigKey('tabConfig', () => {
 | 
				
			||||||
 | 
					  const random = Math.random().toString(36).substring(7);
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    title: 'tab random' + random,
 | 
				
			||||||
 | 
					    description: 'tab config',
 | 
				
			||||||
 | 
					    tabId: useConfigKey('tabId', () => {}),
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const app = useContextKey('app', () => {
 | 
				
			||||||
 | 
					  return new QueryRouterServer();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const umami = useContextKey('umami', () => {
 | 
				
			||||||
 | 
					  if (!window.umami) {
 | 
				
			||||||
 | 
					    (window as any).umami = {
 | 
				
			||||||
 | 
					      track: (event: string, data: any) => {
 | 
				
			||||||
 | 
					        console.log('umami event not found', event, data);
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return window.umami;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 控制 tab 启动
 | 
				
			||||||
 | 
					let load = false;
 | 
				
			||||||
 | 
					let openTime = Date.now();
 | 
				
			||||||
 | 
					let meIsMax = '1'; // 0: 未知, 1: 对比来源,我更大, 2: 不是
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type Msg = {
 | 
				
			||||||
 | 
					  requestId?: string;
 | 
				
			||||||
 | 
					  path: string;
 | 
				
			||||||
 | 
					  key: string;
 | 
				
			||||||
 | 
					  [key: string]: any;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'tab',
 | 
				
			||||||
 | 
					    key: 'introduce',
 | 
				
			||||||
 | 
					    description: '当多个tab打开的时候,找打哦最后打开的,确信那些tabs是有效和打开的',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    const source = ctx.query?.source;
 | 
				
			||||||
 | 
					    const sourceOpenTime = ctx.query?.openTime;
 | 
				
			||||||
 | 
					    const update = ctx.query?.update;
 | 
				
			||||||
 | 
					    if (!source) {
 | 
				
			||||||
 | 
					      ctx.throw?.(400, 'tabId is required');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    openTabs.add(source);
 | 
				
			||||||
 | 
					    if (update) {
 | 
				
			||||||
 | 
					      localStorage.setItem('tab-' + tabId, source);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (!load) {
 | 
				
			||||||
 | 
					      // console.log('soucre time', sourceOpenTime, openTime, sourceOpenTime > openTime);
 | 
				
			||||||
 | 
					      if (openTime < sourceOpenTime) {
 | 
				
			||||||
 | 
					        meIsMax = '2'; // 来源的时间比我大
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    ctx.code = 999;
 | 
				
			||||||
 | 
					    ctx.body = 'ok';
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'tab',
 | 
				
			||||||
 | 
					    key: 'close',
 | 
				
			||||||
 | 
					    description: '当触发关闭事件的时候,清除tab',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    const source = ctx.query?.source;
 | 
				
			||||||
 | 
					    if (!source) {
 | 
				
			||||||
 | 
					      ctx.throw?.(400, 'tabId is required');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    openTabs.delete(source);
 | 
				
			||||||
 | 
					    localStorage.removeItem('tab-' + source);
 | 
				
			||||||
 | 
					    ctx.body = 'ok';
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'tab',
 | 
				
			||||||
 | 
					    key: 'setTabs',
 | 
				
			||||||
 | 
					    description: '已经知道最新的tabs了,通知所有的channel,设置打开的 tab',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    const tabs = ctx.query?.tabs || [];
 | 
				
			||||||
 | 
					    openTabs.clear();
 | 
				
			||||||
 | 
					    tabs.forEach((tab) => {
 | 
				
			||||||
 | 
					      openTabs.add(tab);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    console.log(`[${tabId}]Set open tabs: ${tabs.length}`, openTabs.keys());
 | 
				
			||||||
 | 
					    load = true;
 | 
				
			||||||
 | 
					    ctx.body = 'ok';
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'tab',
 | 
				
			||||||
 | 
					    key: 'getTabs',
 | 
				
			||||||
 | 
					    description: '获取所有的打开的 tab',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    ctx.body = Array.from(openTabs);
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 操作私有 tab
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'tab',
 | 
				
			||||||
 | 
					    key: 'getMe',
 | 
				
			||||||
 | 
					    description: '获取自己的 tab 信息,比如tabId,openTime,tabConfig',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    ctx.body = { tabId, openTime, tabConfig };
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'tab',
 | 
				
			||||||
 | 
					    key: 'me',
 | 
				
			||||||
 | 
					    id: 'me',
 | 
				
			||||||
 | 
					    description: '验证是否是自己的标签页面,如果是自己则继续,否则返回错误',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    // check me
 | 
				
			||||||
 | 
					    const to = ctx.query?.to;
 | 
				
			||||||
 | 
					    if (to !== tabId) {
 | 
				
			||||||
 | 
					      // not me
 | 
				
			||||||
 | 
					      ctx.throw?.(840, 'not me');
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    ctx.body = 'ok';
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'channel',
 | 
				
			||||||
 | 
					    key: 'postAllTabs',
 | 
				
			||||||
 | 
					    description: '向所有的 tab 发送消息,如果payload有hasResponse,则等待所有的 tab 返回消息',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    const data = ctx.query?.data;
 | 
				
			||||||
 | 
					    const hasResponse = ctx.query?.hasResponse;
 | 
				
			||||||
 | 
					    if (!data?.path) {
 | 
				
			||||||
 | 
					      ctx.throw?.(400, 'path is required');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const res = await new Promise((resolve) => {
 | 
				
			||||||
 | 
					      if (hasResponse) {
 | 
				
			||||||
 | 
					        postAllTabs(data, (res) => {
 | 
				
			||||||
 | 
					          resolve(res);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        postAllTabs(data);
 | 
				
			||||||
 | 
					        resolve('ok');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    ctx.body = res || 'ok';
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'channel',
 | 
				
			||||||
 | 
					    key: 'postTab',
 | 
				
			||||||
 | 
					    description: '向指定的 tab 发送消息,如果payload有hasResponse,则等待指定的 tab 返回消息',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    const data = ctx.query?.data;
 | 
				
			||||||
 | 
					    const hasResponse = ctx.query?.hasResponse;
 | 
				
			||||||
 | 
					    if (!data?.path) {
 | 
				
			||||||
 | 
					      ctx.throw?.(400, 'path is required');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const res = await new Promise((resolve) => {
 | 
				
			||||||
 | 
					      if (hasResponse) {
 | 
				
			||||||
 | 
					        postTab(data, (res) => {
 | 
				
			||||||
 | 
					          resolve(res);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        postTab(data);
 | 
				
			||||||
 | 
					        resolve('ok');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    ctx.body = res || 'ok';
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'tab',
 | 
				
			||||||
 | 
					    key: 'setConfig',
 | 
				
			||||||
 | 
					    description: '设置 tab 的配置信息',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    const config = ctx.query?.config;
 | 
				
			||||||
 | 
					    if (config) {
 | 
				
			||||||
 | 
					      Object.assign(tabConfig, config);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    ctx.body = tabConfig;
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app
 | 
				
			||||||
 | 
					  .route({
 | 
				
			||||||
 | 
					    path: 'tab',
 | 
				
			||||||
 | 
					    key: 'getConfig',
 | 
				
			||||||
 | 
					    description: '获取 tab 的配置信息',
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .define(async (ctx) => {
 | 
				
			||||||
 | 
					    ctx.body = tabConfig;
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .addTo(app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const channel = useContextKey('channel', () => {
 | 
				
			||||||
 | 
					  return new BroadcastChannel('tab-channel');
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					channel.addEventListener('message', (event) => {
 | 
				
			||||||
 | 
					  const { requestId, to, source, responseId } = event.data;
 | 
				
			||||||
 | 
					  if (responseId && to === tabId) {
 | 
				
			||||||
 | 
					    // 有 id 的消息, 这是这个模块请求返回回来的消息,不被其他模块处理。
 | 
				
			||||||
 | 
					    emitter.emit(responseId, event.data);
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // console.log('channel message', event.data);
 | 
				
			||||||
 | 
					  if (to === 'all' || to === tabId) {
 | 
				
			||||||
 | 
					    const { path, key, payload, ...rest } = event.data;
 | 
				
			||||||
 | 
					    if (!path) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    app
 | 
				
			||||||
 | 
					      .run({
 | 
				
			||||||
 | 
					        path,
 | 
				
			||||||
 | 
					        key,
 | 
				
			||||||
 | 
					        payload: {
 | 
				
			||||||
 | 
					          ...rest,
 | 
				
			||||||
 | 
					          ...payload,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .then((res) => {
 | 
				
			||||||
 | 
					        const { data, message, code } = res;
 | 
				
			||||||
 | 
					        if (requestId) {
 | 
				
			||||||
 | 
					          if (code !== 999) {
 | 
				
			||||||
 | 
					            console.log('channel response', requestId, res);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          const msg = {
 | 
				
			||||||
 | 
					            // 返回数据一般没有 requestId,如果有,在res中自己放置
 | 
				
			||||||
 | 
					            responseId: requestId,
 | 
				
			||||||
 | 
					            to: source,
 | 
				
			||||||
 | 
					            source: tabId,
 | 
				
			||||||
 | 
					            data: data,
 | 
				
			||||||
 | 
					            message,
 | 
				
			||||||
 | 
					            code,
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          if (data?.requestId) {
 | 
				
			||||||
 | 
					            msg['requestId'] = data.requestId;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          channel.postMessage(msg);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .catch((err) => {
 | 
				
			||||||
 | 
					        console.error(tabId, requestId, err);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const postAllTabs = (msg: Msg, callback?: (data: any[], error?: any[]) => any, timeout = 3 * 60 * 1000) => {
 | 
				
			||||||
 | 
					  const action = {
 | 
				
			||||||
 | 
					    ...msg,
 | 
				
			||||||
 | 
					    to: 'all',
 | 
				
			||||||
 | 
					    source: tabId,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  if (callback) {
 | 
				
			||||||
 | 
					    const requestId = nanoid(8);
 | 
				
			||||||
 | 
					    action.requestId = requestId;
 | 
				
			||||||
 | 
					    const res: any = [];
 | 
				
			||||||
 | 
					    const error: any = [];
 | 
				
			||||||
 | 
					    emitter.on(requestId, (data) => {
 | 
				
			||||||
 | 
					      res.push(data);
 | 
				
			||||||
 | 
					      // console.log('postAllTabs callback', data, res.length, openTabs.size);
 | 
				
			||||||
 | 
					      if (res.length >= openTabs.size - 1) {
 | 
				
			||||||
 | 
					        callback(res);
 | 
				
			||||||
 | 
					        emitter.off(requestId);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    setTimeout(() => {
 | 
				
			||||||
 | 
					      const tabs = [...openTabs];
 | 
				
			||||||
 | 
					      tabs.forEach((tab) => {
 | 
				
			||||||
 | 
					        if (tab === tabId) {
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (!res.find((r) => r.source === tab)) {
 | 
				
			||||||
 | 
					          error.push({ code: 500, message: 'timeout', tab, source: tab, to: tabId });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      callback(res, error);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      emitter.off(requestId);
 | 
				
			||||||
 | 
					    }, timeout);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  channel.postMessage(action);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const postTab = (msg: Msg, callback?: (data: any) => any, timeout = 3 * 60 * 1000) => {
 | 
				
			||||||
 | 
					  const action = {
 | 
				
			||||||
 | 
					    ...msg,
 | 
				
			||||||
 | 
					    to: msg.to,
 | 
				
			||||||
 | 
					    source: tabId,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  if (callback) {
 | 
				
			||||||
 | 
					    const requestId = nanoid(8);
 | 
				
			||||||
 | 
					    action.requestId = requestId;
 | 
				
			||||||
 | 
					    emitter.on(requestId, (data) => {
 | 
				
			||||||
 | 
					      callback(data);
 | 
				
			||||||
 | 
					      emitter.off(requestId);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    setTimeout(() => {
 | 
				
			||||||
 | 
					      callback({ code: 500, message: 'timeout' });
 | 
				
			||||||
 | 
					      emitter.off(requestId);
 | 
				
			||||||
 | 
					    }, timeout);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  channel.postMessage(action);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					const channelFn = {
 | 
				
			||||||
 | 
					  postAllTabs,
 | 
				
			||||||
 | 
					  postTab,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					useConfigKey('channelMsg', () => {
 | 
				
			||||||
 | 
					  return channelFn;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getTabs = (filter = true) => {
 | 
				
			||||||
 | 
					  const allTabs = Object.keys(localStorage).filter((key) => key.startsWith('tab-'));
 | 
				
			||||||
 | 
					  const tabs = allTabs.filter((key) => {
 | 
				
			||||||
 | 
					    if (!filter) {
 | 
				
			||||||
 | 
					      return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const tab = localStorage.getItem(key);
 | 
				
			||||||
 | 
					    if (tab === tabId) {
 | 
				
			||||||
 | 
					      return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    localStorage.removeItem(key);
 | 
				
			||||||
 | 
					    return false;
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  return tabs;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getOtherTabs = async () => {
 | 
				
			||||||
 | 
					  const res = await app.run({
 | 
				
			||||||
 | 
					    path: 'channel',
 | 
				
			||||||
 | 
					    key: 'postAllTabs',
 | 
				
			||||||
 | 
					    payload: {
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        path: 'tab',
 | 
				
			||||||
 | 
					        key: 'getConfig',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      hasResponse: true,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  console.log('getOtherTabs', res);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// @ts-ignore
 | 
				
			||||||
 | 
					window.getOtherTabs = getOtherTabs;
 | 
				
			||||||
 | 
					const checkTabs = async () => {
 | 
				
			||||||
 | 
					  postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime, update: true } });
 | 
				
			||||||
 | 
					  await sleep(1000);
 | 
				
			||||||
 | 
					  const tabs = getTabs();
 | 
				
			||||||
 | 
					  postAllTabs({ path: 'tab', key: 'setTabs', payload: { tabs: tabs.map((tab) => tab.replace('tab-', '')) } });
 | 
				
			||||||
 | 
					  openTabs.clear();
 | 
				
			||||||
 | 
					  tabs.forEach((tab) => {
 | 
				
			||||||
 | 
					    openTabs.add(tab.replace('tab-', ''));
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * 初始化 tab
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					const init = async () => {
 | 
				
			||||||
 | 
					  localStorage.setItem('tab-' + tabId, tabId);
 | 
				
			||||||
 | 
					  // 向其他 tab 发送消息, 页面已经打开
 | 
				
			||||||
 | 
					  postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime } });
 | 
				
			||||||
 | 
					  window.addEventListener('beforeunload', () => {
 | 
				
			||||||
 | 
					    // 向其他 tab 发送消息, 页面即将关闭
 | 
				
			||||||
 | 
					    localStorage.removeItem('tab-' + tabId);
 | 
				
			||||||
 | 
					    console.log('close tab', tabId);
 | 
				
			||||||
 | 
					    postAllTabs({ path: 'tab', key: 'close' });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  introduceMe();
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					const introduceMe = () => {
 | 
				
			||||||
 | 
					  if (meIsMax === '2') {
 | 
				
			||||||
 | 
					    // 我知道我不是最大的了,我自己就不再发送消息了
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime } });
 | 
				
			||||||
 | 
					  if (!load) {
 | 
				
			||||||
 | 
					    const time = Date.now() - openTime;
 | 
				
			||||||
 | 
					    if (time > 2100) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    setTimeout(() => {
 | 
				
			||||||
 | 
					      introduceMe();
 | 
				
			||||||
 | 
					    }, 300);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					// umami.track('tab', { tabId: tabId });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					window.onload = () => {
 | 
				
			||||||
 | 
					  setTimeout(() => {
 | 
				
			||||||
 | 
					    if (meIsMax === '1') {
 | 
				
			||||||
 | 
					      console.log('I am max', openTime);
 | 
				
			||||||
 | 
					      checkTabs();
 | 
				
			||||||
 | 
					      load = true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, 2000);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					init();
 | 
				
			||||||
							
								
								
									
										48
									
								
								src/utils/check-connect.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/utils/check-connect.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
				
			|||||||
 | 
					type TaskData = {
 | 
				
			||||||
 | 
					  element?: HTMLElement;
 | 
				
			||||||
 | 
					  onUnload?: () => void;
 | 
				
			||||||
 | 
					  onLoad?: () => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					// 定义任务队列
 | 
				
			||||||
 | 
					const loadTasks: TaskData[] = [];
 | 
				
			||||||
 | 
					const unloadTasks: TaskData[] = [];
 | 
				
			||||||
 | 
					let isSchedulerLoadRunning = false; // 调度器状态,加载
 | 
				
			||||||
 | 
					let isSchedulerUnLoadRunning = false; // 调度器状态
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const addTask = (task: TaskData) => {
 | 
				
			||||||
 | 
					  if (task.onLoad || task.onUnload) {
 | 
				
			||||||
 | 
					    loadTasks.push(task);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  if (!isSchedulerLoadRunning) {
 | 
				
			||||||
 | 
					    isSchedulerLoadRunning = true;
 | 
				
			||||||
 | 
					    requestIdleCallback(processTasks);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export const addUnloadTask = (task: TaskData) => {
 | 
				
			||||||
 | 
					  if (task.onUnload) {
 | 
				
			||||||
 | 
					    unloadTasks.push(task);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					// 调度函数
 | 
				
			||||||
 | 
					const processTasks: IdleRequestCallback = (deadline) => {
 | 
				
			||||||
 | 
					  // 优先处理高优先级任务
 | 
				
			||||||
 | 
					  while (loadTasks.length && deadline.timeRemaining() > 0) {
 | 
				
			||||||
 | 
					    const task = loadTasks.shift();
 | 
				
			||||||
 | 
					    if (task?.element?.isConnected) {
 | 
				
			||||||
 | 
					      task.onLoad?.();
 | 
				
			||||||
 | 
					      if (task.onUnload) {
 | 
				
			||||||
 | 
					        addUnloadTask(task);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else if (task) {
 | 
				
			||||||
 | 
					      addTask(task);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  // 检查是否还有任务
 | 
				
			||||||
 | 
					  if (loadTasks.length) {
 | 
				
			||||||
 | 
					    requestIdleCallback(processTasks);
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    // 没有任务,停止调度器
 | 
				
			||||||
 | 
					    isSchedulerLoadRunning = false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					requestIdleCallback(processTasks);
 | 
				
			||||||
							
								
								
									
										47
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					import path from 'path';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const root = path.resolve(process.cwd());
 | 
				
			||||||
 | 
					const contents = ['./src/**/*.{ts,tsx,html}', './src/**/*.css'];
 | 
				
			||||||
 | 
					const content = contents.map((item) => path.join(root, item));
 | 
				
			||||||
 | 
					/** @type {import('tailwindcss').Config} */
 | 
				
			||||||
 | 
					export default {
 | 
				
			||||||
 | 
					  // darkMode: ['class'],
 | 
				
			||||||
 | 
					  content: contents,
 | 
				
			||||||
 | 
					  plugins: [
 | 
				
			||||||
 | 
					    require('@tailwindcss/aspect-ratio'), //
 | 
				
			||||||
 | 
					    require('@tailwindcss/typography'),
 | 
				
			||||||
 | 
					    require('tailwindcss-animate'),
 | 
				
			||||||
 | 
					    require('@build/tailwind'),
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  theme: {
 | 
				
			||||||
 | 
					    extend: {
 | 
				
			||||||
 | 
					      fontFamily: {
 | 
				
			||||||
 | 
					        mon: ['Montserrat', 'sans-serif'], // 定义自定义字体族
 | 
				
			||||||
 | 
					        rob: ['Roboto', 'sans-serif'],
 | 
				
			||||||
 | 
					        int: ['Inter', 'sans-serif'],
 | 
				
			||||||
 | 
					        orb: ['Orbitron', 'sans-serif'],
 | 
				
			||||||
 | 
					        din: ['DIN', 'sans-serif'],
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    screen: {
 | 
				
			||||||
 | 
					      sm: '640px',
 | 
				
			||||||
 | 
					      // => @media (min-width: 640px) { ... }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      md: '768px',
 | 
				
			||||||
 | 
					      // => @media (min-width: 768px) { ... }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      lg: '1024px',
 | 
				
			||||||
 | 
					      // => @media (min-width: 1024px) { ... }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      xl: '1280px',
 | 
				
			||||||
 | 
					      // => @media (min-width: 1280px) { ... }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      '2xl': '1536px',
 | 
				
			||||||
 | 
					      // => @media (min-width: 1536px) { ... }
 | 
				
			||||||
 | 
					      '3xl': '1920px',
 | 
				
			||||||
 | 
					      // => @media (min-width: 1920) { ... }
 | 
				
			||||||
 | 
					      '4xl': '2560px',
 | 
				
			||||||
 | 
					      // => @media (min-width: 2560) { ... }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										42
									
								
								tsconfig.app.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								tsconfig.app.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "compilerOptions": {
 | 
				
			||||||
 | 
					    "jsx": "react",
 | 
				
			||||||
 | 
					    "target": "ES2020",
 | 
				
			||||||
 | 
					    "useDefineForClassFields": true,
 | 
				
			||||||
 | 
					    "lib": [
 | 
				
			||||||
 | 
					      "ES2020",
 | 
				
			||||||
 | 
					      "DOM",
 | 
				
			||||||
 | 
					      "DOM.Iterable"
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "module": "ESNext",
 | 
				
			||||||
 | 
					    "skipLibCheck": true,
 | 
				
			||||||
 | 
					    /* Bundler mode */
 | 
				
			||||||
 | 
					    "moduleResolution": "bundler",
 | 
				
			||||||
 | 
					    "allowImportingTsExtensions": true,
 | 
				
			||||||
 | 
					    "isolatedModules": true,
 | 
				
			||||||
 | 
					    "moduleDetection": "force",
 | 
				
			||||||
 | 
					    "noEmit": true,
 | 
				
			||||||
 | 
					    "jsxFragmentFactory": "Fragment",
 | 
				
			||||||
 | 
					    "jsxFactory": "h",
 | 
				
			||||||
 | 
					    "baseUrl": "./",
 | 
				
			||||||
 | 
					    "typeRoots": [
 | 
				
			||||||
 | 
					      "./node_modules/@types",
 | 
				
			||||||
 | 
					      "./node_modules/@kevisual/types",
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "paths": {
 | 
				
			||||||
 | 
					      "@/*": [
 | 
				
			||||||
 | 
					        "src/*"
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    /* Linting */
 | 
				
			||||||
 | 
					    "strict": true,
 | 
				
			||||||
 | 
					    "noImplicitAny": false,
 | 
				
			||||||
 | 
					    "noUnusedLocals": false,
 | 
				
			||||||
 | 
					    "noUnusedParameters": false,
 | 
				
			||||||
 | 
					    "noFallthroughCasesInSwitch": true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "include": [
 | 
				
			||||||
 | 
					    "src",
 | 
				
			||||||
 | 
					    "typings.d.ts"
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										7
									
								
								tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "files": [],
 | 
				
			||||||
 | 
					  "references": [
 | 
				
			||||||
 | 
					    { "path": "./tsconfig.app.json" },
 | 
				
			||||||
 | 
					    { "path": "./tsconfig.node.json" }
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										22
									
								
								tsconfig.node.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								tsconfig.node.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "compilerOptions": {
 | 
				
			||||||
 | 
					    "target": "ES2022",
 | 
				
			||||||
 | 
					    "lib": ["ES2023"],
 | 
				
			||||||
 | 
					    "module": "ESNext",
 | 
				
			||||||
 | 
					    "skipLibCheck": true,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Bundler mode */
 | 
				
			||||||
 | 
					    "moduleResolution": "bundler",
 | 
				
			||||||
 | 
					    "allowImportingTsExtensions": true,
 | 
				
			||||||
 | 
					    "isolatedModules": true,
 | 
				
			||||||
 | 
					    "moduleDetection": "force",
 | 
				
			||||||
 | 
					    "noEmit": true,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Linting */
 | 
				
			||||||
 | 
					    "strict": true,
 | 
				
			||||||
 | 
					    "noUnusedLocals": true,
 | 
				
			||||||
 | 
					    "noUnusedParameters": true,
 | 
				
			||||||
 | 
					    "noFallthroughCasesInSwitch": true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "include": ["vite.config.ts"]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										8
									
								
								typing.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								typing.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					// typing.d.ts 或 global.d.ts
 | 
				
			||||||
 | 
					declare global {
 | 
				
			||||||
 | 
					  namespace JSX {
 | 
				
			||||||
 | 
					    type Element = HTMLElement; // 将 JSX.Element 设置为 HTMLElement
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export {};
 | 
				
			||||||
							
								
								
									
										46
									
								
								vite.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								vite.config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
				
			|||||||
 | 
					import { defineConfig } from 'vite';
 | 
				
			||||||
 | 
					import path from 'path';
 | 
				
			||||||
 | 
					// import react from '@vitejs/plugin-react';
 | 
				
			||||||
 | 
					import tailwindcss from 'tailwindcss';
 | 
				
			||||||
 | 
					import autoprefixer from 'autoprefixer';
 | 
				
			||||||
 | 
					import nesting from 'tailwindcss/nesting';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default defineConfig({
 | 
				
			||||||
 | 
					  root: '.',
 | 
				
			||||||
 | 
					  // plugins: [react()],
 | 
				
			||||||
 | 
					  css: {
 | 
				
			||||||
 | 
					    postcss: {
 | 
				
			||||||
 | 
					      // @ts-ignore
 | 
				
			||||||
 | 
					      plugins: [nesting, tailwindcss, autoprefixer],
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  resolve: {
 | 
				
			||||||
 | 
					    alias: {
 | 
				
			||||||
 | 
					      '@': path.resolve(__dirname, './src'),
 | 
				
			||||||
 | 
					      // react: './utils/h.ts', // 将 react 替换为自定义的 JSX 库
 | 
				
			||||||
 | 
					      // 'react-dom': './utils/h.ts', // 将 react-dom 替换为自定义的 JSX 库
 | 
				
			||||||
 | 
					      // 上到下,上面的先被匹配
 | 
				
			||||||
 | 
					      // '@kevisual/store/config': 'https://kevisual.xiongxiao.me/system/lib/web-config.js', // 将本地路径替换为远程 URL
 | 
				
			||||||
 | 
					      // '@kevisual/store/context': 'https://kevisual.xiongxiao.me/system/lib/web-context.js', // 将本地路径替换为远程 URL
 | 
				
			||||||
 | 
					      // '@kevisual/store': 'https://kevisual.xiongxiao.me/system/lib/store.js', // 将本地路径替换为远程 URL
 | 
				
			||||||
 | 
					      // '@kevisual/router/browser': 'https://kevisual.xiongxiao.me/system/lib/router-browser.js', // 将本地路径替换为远程 URL
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  build: {
 | 
				
			||||||
 | 
					    minify: false,
 | 
				
			||||||
 | 
					    outDir: './dist',
 | 
				
			||||||
 | 
					    rollupOptions: {
 | 
				
			||||||
 | 
					      external: ['react', 'react-dom'],
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  optimizeDeps: {
 | 
				
			||||||
 | 
					    exclude: ['react', 'react-dom'], // 排除 react 和 react-dom 以避免打包
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  esbuild: {
 | 
				
			||||||
 | 
					    jsxFactory: 'h',
 | 
				
			||||||
 | 
					    jsxFragment: 'Fragment',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  server: {
 | 
				
			||||||
 | 
					    port: 5002,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
		Reference in New Issue
	
	Block a user